> ## Documentation Index
> Fetch the complete documentation index at: https://docs.twenty.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Front Components

> Build React components that render inside Twenty's UI with sandboxed isolation.

Front components are React components that render directly inside Twenty's UI. They run in an **isolated Web Worker** using Remote DOM — your code executes inside a sandboxed, opaque-origin iframe, yet its UI still renders natively in the page rather than being confined to that iframe.

<Warning>
  Front components are still under active development. Your code runs against a
  partial DOM, not a real browser page, so advanced usages can fail, often
  silently. See [Current limitations](#current-limitations).
</Warning>

## Where front components can be used

Front components can render in four 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](/developers/extend/apps/layout/page-layouts). When configuring a dashboard or a record page layout, users can add a front component widget.
* **App settings** — Pointed at by a [`defineSettingsMenuItem()`](#settings-menu-items) declaration, the front component renders the page behind one of the app's settings menu items. An app can declare several.
* **AI chat** — A [logic function exposed as an AI tool](/developers/extend/apps/logic/logic-functions#exposing-a-function-as-an-ai-tool-or-workflow-action) can name a front component, which then renders that tool's calls inline in the chat in place of the default step row.

A front component on its own isn't reachable from the UI — you need to *surface* it. The four ways to do that are:

* **Pair it with a [command menu item](/developers/extend/apps/layout/command-menu-items)** — registers it in the command menu (Cmd+K) and, optionally, as a pinned quick-action.
* **Embed it as a widget in a [page layout](/developers/extend/apps/layout/page-layouts)** — places it on a record's detail page or dashboard.
* **Point a [`defineSettingsMenuItem()`](#settings-menu-items) at it** — renders it behind one of the app's settings menu items.
* **Name it from a [logic function's `toolTriggerSettings`](/developers/extend/apps/logic/logic-functions#exposing-a-function-as-an-ai-tool-or-workflow-action)** — renders the tool's calls in the AI chat.

## Basic example

The quickest way to see a front component in action is to pair it with a [`defineCommandMenuItem`](/developers/extend/apps/layout/command-menu-items), so it appears as a quick-action button in the top-right corner of the page:

```tsx src/front-components/hello-world.tsx theme={null}
import { defineFrontComponent } from 'twenty-sdk/define';

const HelloWorld = () => {
  return (
    <div style={{ padding: '20px', fontFamily: 'sans-serif' }}>
      <h1>Hello from my app!</h1>
      <p>This component renders inside Twenty.</p>
    </div>
  );
};

export default defineFrontComponent({
  universalIdentifier: '74c526eb-cb68-4cf7-b05c-0dd8c288d948',
  name: 'hello-world',
  description: 'A simple front component',
  component: HelloWorld,
});
```

```ts src/command-menu-items/hello-world.command-menu-item.ts theme={null}
import { defineCommandMenuItem } from 'twenty-sdk/define';

export default defineCommandMenuItem({
  universalIdentifier: 'd4e5f6a7-b8c9-0123-defa-456789012345',
  shortLabel: 'Hello',
  label: 'Hello World',
  isPinned: true,
  availabilityType: 'GLOBAL',
  frontComponentUniversalIdentifier: '74c526eb-cb68-4cf7-b05c-0dd8c288d948',
});
```

After syncing with `yarn twenty dev` (or running a one-shot `yarn twenty apply`), the quick action appears in the top-right corner of the page:

<div style={{ textAlign: 'center' }}>
  <img src="https://mintcdn.com/twenty/q7TCG2vqA_qoAvgz/images/docs/developers/extends/apps/quick-action.png?fit=max&auto=format&n=q7TCG2vqA_qoAvgz&q=85&s=d2d8368806f808ff6f239f32537d224b" alt="Quick action button in the top-right corner" width="3024" height="1502" data-path="images/docs/developers/extends/apps/quick-action.png" />
</div>

Click it to render the component inline.

## 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) |
| `widgetAvailability` | No | Widget picker that offers the component: record pages, optionally narrowed to objects, or dashboards (see 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 [Page Layouts](/developers/extend/apps/layout/page-layouts) for details.

`widgetAvailability` decides which widget picker offers the component while a page layout is being edited. A front component is built either for record pages, where it works on the shown record, or for dashboards, so it declares one `pageLayoutType`:

| `widgetAvailability` | Offered in |
| - | - |
| omitted | no widget picker |
| `{ pageLayoutType: PageLayoutType.RECORD_PAGE }` | every record page |
| `{ pageLayoutType: PageLayoutType.RECORD_PAGE, objectUniversalIdentifiers: [...] }` | the record pages of those objects |
| `{ pageLayoutType: PageLayoutType.DASHBOARD }` | dashboards |

```tsx theme={null}
import {
  defineFrontComponent,
  PageLayoutType,
  STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';

export default defineFrontComponent({
  universalIdentifier: '...',
  name: 'company-notes',
  component: CompanyNotes,
  widgetAvailability: {
    pageLayoutType: PageLayoutType.RECORD_PAGE,
    objectUniversalIdentifiers: [
      STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
    ],
  },
});
```

Leave `widgetAvailability` out for a component that is only opened from a command, a [settings menu item](#settings-menu-items) or a timeline row. It never limits where a component is displayed: commands, settings menu items and the page layouts your app declares render it whatever its value. The build warns about a visible component that declares none and is opened from nowhere else.

## Settings menu items

An app can add its own entries to the settings menu, each rendering a front component. Declare one with `defineSettingsMenuItem()` in its own file, pointing at the component that renders it:

```tsx src/settings-menu-items/sync-settings.ts theme={null}
import { defineSettingsMenuItem } from 'twenty-sdk/define';

export default defineSettingsMenuItem({
  universalIdentifier: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
  frontComponentUniversalIdentifier: '88c15ae2-5f87-4a6b-b48f-1974bbe62eb7',
  title: 'Sync',
  icon: 'IconRefresh',
  position: 1,
});
```

The menu item points at the front component rather than the component declaring itself a settings entry. One component can therefore back several menu items, and an item can be repointed at a different component without touching either.

Each item renders as a tab on the app's page under **Settings > Apps**, after **General**, in `position` order.

Declare as many as you need. Twenty's own **General** item — auto-upgrade, App URL and connections — always comes first and cannot be overridden by an app; App URL only shows when the app has HTTP-triggered functions.

### Fields

| Field | Description |
| - | - |
| `universalIdentifier` | A UUID you generate once and own, stable across syncs |
| `frontComponentUniversalIdentifier` | The `universalIdentifier` of the front component this item renders |
| `title` | The item's label. `General` is reserved and fails the build |
| `icon` *(optional)* | Icon name, resolved client-side; an unknown name falls back to a default |
| `position` *(optional)* | Sort order, ascending. Defaults to `0` |
| `scope` *(optional)* | `WORKSPACE` (default) or `USER` |

`position` is a decimal, so an item can be slotted between two existing ones without renumbering. Items sort by ascending `position`, ties broken on `universalIdentifier` so the order is the same in every workspace. Two items of one app cannot share a position in the same scope — the sync refuses it rather than letting install order decide which renders first.

### Workspace and user scope

`scope` says who an item is configured by:

* **`WORKSPACE`** (default) — configured once for the whole workspace, alongside the other workspace settings. These are the items that render as tabs on the app's page.
* **`USER`** — configured by each member for themselves, under their own user settings.

<Note>
  A `USER` item renders as a tab on the app's page under **Settings > App
  preferences**, where only the signed-in member sees it, and only when their
  role grants the `CONNECTED_ACCOUNTS` permission. That page is not enabled in
  every workspace yet: where it is not, a `USER` item is persisted and synced
  but nothing renders it.
</Note>

A `USER` item runs as the member viewing it. To store something per person, declare the variable with `scope: 'USER'`: `getApplicationVariable` returns the viewing member's value, and your component saves it with `updateMyUserApplicationVariable`, which writes that member's value only and needs `SystemPermissionFlag.CONNECTED_ACCOUNTS` in your app's role, if it declares one (see [Reading and updating variables at runtime](#reading-and-updating-variables-at-runtime)). Like a `WORKSPACE` item, a `USER` item replaces the generated **Variables** form, so it should let the member set every user variable your app needs. A workspace variable holds one value shared by everyone, so one member's save would overwrite another's.

<Note>
  `defineSettingsFrontComponent()` is deprecated in favor of
  `defineSettingsMenuItem()`. An app still using it keeps working — its
  component is treated as a single **Settings** menu item — but it can only ever
  declare one, which is what `defineSettingsMenuItem()` exists to lift.
</Note>

## 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 (`isHeadless: true`)** — 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.

```tsx src/front-components/sync-tracker.tsx theme={null}
import { defineFrontComponent } from 'twenty-sdk/define';
import {
  useSelectedRecordIds,
  enqueueSnackbar,
} from 'twenty-sdk/front-component';
import { useEffect } from 'react';

const SyncTracker = () => {
  const [recordId] = useSelectedRecordIds();

  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.

## 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/front-component`:

* **`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 content in the side panel. Routed pages use the same typed `to`, `params`, and `queryParams` props as `CommandLink`, plus optional `hash`, `pageTitle`, and `resetNavigationStack` props. The workspace route registry decides whether the requested `AppPath` supports the side panel. `pageTitle` overrides the panel header title, while `resetNavigationStack` clears earlier panel pages before opening the route. Purpose-built panel tools use their own page-specific props.

Here is a full example of a headless front component using `Command` to run an action from the command menu:

```tsx src/front-components/run-action.tsx theme={null}
import { defineFrontComponent } from 'twenty-sdk/define';
import { Command } from 'twenty-sdk/front-component';
import { CoreApiClient } from 'twenty-client-sdk/core';

const RunAction = () => {
  const execute = async () => {
    const client = new CoreApiClient();

    await client.mutation({
      createTask: {
        __args: { data: { title: 'Created by my app' } },
        id: true,
      },
    });
  };

  return <Command execute={execute} />;
};

export default defineFrontComponent({
  universalIdentifier: 'e5f6a7b8-c9d0-1234-efab-345678901234',
  name: 'run-action',
  description: 'Creates a task from the command menu',
  component: RunAction,
  isHeadless: true,
});
```

```ts src/command-menu-items/run-action.command-menu-item.ts theme={null}
import { defineCommandMenuItem } from 'twenty-sdk/define';

export default defineCommandMenuItem({
  universalIdentifier: 'f6a7b8c9-d0e1-2345-fabc-456789012345',
  label: 'Run my action',
  frontComponentUniversalIdentifier: 'e5f6a7b8-c9d0-1234-efab-345678901234',
});
```

And an example using `CommandModal` to ask for confirmation before executing:

```tsx src/front-components/delete-draft.tsx theme={null}
import { defineFrontComponent } from 'twenty-sdk/define';
import { CommandModal } from 'twenty-sdk/front-component';

const DeleteDraft = () => {
  const execute = async () => {
    // perform the deletion
  };

  return (
    <CommandModal
      title="Delete draft?"
      subtitle="This action cannot be undone."
      execute={execute}
      confirmButtonText="Delete"
      confirmButtonAccent="danger"
    />
  );
};

export default defineFrontComponent({
  universalIdentifier: 'a7b8c9d0-e1f2-3456-abcd-567890123456',
  name: 'delete-draft',
  description: 'Deletes a draft with confirmation',
  component: DeleteDraft,
  isHeadless: true,
});
```

And an example using `CommandOpenSidePanelPage` to open the current record in the side panel on a specific tab. `hash` is a page layout tab id without the leading `#` (default layouts use ids like `company-tab-emails` or `company-tab-timeline`; custom layouts use the tab's own id). If the id doesn't exist in the record's layout, the default tab opens instead:

```tsx src/front-components/open-company-emails.tsx theme={null}
import { defineFrontComponent } from 'twenty-sdk/define';
import {
  AppPath,
  CommandOpenSidePanelPage,
  useSelectedRecordIds,
} from 'twenty-sdk/front-component';

const OpenCompanyEmails = () => {
  const selectedRecordIds = useSelectedRecordIds();
  const recordId = selectedRecordIds.length === 1 ? selectedRecordIds[0] : null;

  if (!recordId) {
    return null;
  }

  return (
    <CommandOpenSidePanelPage
      to={AppPath.RecordShowPage}
      params={{
        objectNameSingular: 'company',
        objectRecordId: recordId,
      }}
      hash="company-tab-emails"
      resetNavigationStack={false}
    />
  );
};

export default defineFrontComponent({
  universalIdentifier: 'b8c9d0e1-f2a3-4567-bcde-678901234567',
  name: 'open-company-emails',
  description: 'Opens the current company on its Emails tab',
  component: OpenCompanyEmails,
  isHeadless: true,
});
```

## Calling a logic function

Front components run browser-side in a Web Worker sandboxed inside an opaque-origin iframe, while [logic functions](/developers/extend/apps/logic/logic-functions) run server-side. There is no direct in-process call between the two — instead, a front component reaches a logic function over HTTP.

A logic function declared with `httpRouteTriggerSettings` is reachable over HTTP at its route path. `RestApiClient` treats paths starting with `/s/` as app routes, resolves them to the URL your functions are served from, and authenticates them with `TWENTY_APP_ACCESS_TOKEN`.

> **On Twenty Cloud, HTTP-triggered logic functions are served on a dedicated per-workspace domain** at `https://<your-workspace-subdomain>.withtwenty.com<path>`. For external callers, copy the exact URL from the function's **HTTP trigger** settings or the application's **Settings** tab.

A headless front component can run the call on mount via the `Command` component, then unmount automatically:

```tsx src/front-components/sync-prs.tsx theme={null}
import { RestApiClient } from 'twenty-client-sdk/rest';
import { defineFrontComponent } from 'twenty-sdk/define';
import { Command } from 'twenty-sdk/front-component';

const SyncPrs = () => {
  const execute = async () => {
    await new RestApiClient().post('/s/github/fetch-prs', {
      owner: 'twentyhq',
      repo: 'twenty',
    });
  };

  return <Command execute={execute} />;
};

export default defineFrontComponent({
  universalIdentifier: '...',
  name: 'sync-prs',
  description: 'Triggers the fetch-prs logic function',
  isHeadless: true,
  component: SyncPrs,
});
```

The path passed to `RestApiClient` is the logic function's `httpRouteTriggerSettings.path`, prefixed with `/s`. Keep `isAuthRequired: true`; the `TWENTY_APP_ACCESS_TOKEN` Twenty mints for your component authenticates the request:

```ts src/logic-functions/fetch-prs.logic-function.ts theme={null}
import { defineLogicFunction } from 'twenty-sdk/define';
import type { RoutePayload } from 'twenty-sdk/logic-function';

const handler = async (event: RoutePayload) => {
  const { owner, repo } = (event.body ?? {}) as { owner: string; repo: string };
  // ...fetch from GitHub and persist records...
  return { ok: true };
};

export default defineLogicFunction({
  universalIdentifier: '...',
  name: 'fetch-prs',
  handler,
  httpRouteTriggerSettings: {
    path: '/github/fetch-prs',
    httpMethod: 'POST',
    isAuthRequired: true,
  },
});
```

<Note>
  `TWENTY_APP_ACCESS_TOKEN` is injected automatically — see [Application
  variables](#application-variables). Because secret application variables are
  never exposed to front components, keep API keys and other sensitive logic in
  the logic function, not in the front component.
</Note>

### Calling the Twenty REST API

To call app HTTP routes or read and write Twenty records from a front component, use `RestApiClient` from `twenty-client-sdk/rest`. It sends `/s/...` paths to your workspace's functions base URL and every other path, including `/rest/...`, to `TWENTY_API_URL`.

It always acts as the person looking at the page. `runAs: 'application'` is a logic-function option only: a component never receives your application's own token, so asking for it here throws. Put work that needs the app's own access behind a logic function and call that instead.

| Method | Description |
| - | - |
| `get(path, options?)` | Sends a `GET` request |
| `post(path, body?, options?)` | Sends a `POST` request |
| `put(path, body?, options?)` | Sends a `PUT` request |
| `patch(path, body?, options?)` | Sends a `PATCH` request |
| `delete(path, options?)` | Sends a `DELETE` request |
| `request(method, path, options?)` | Generic request with any HTTP method |
| `resolveUrl(path, options?)` | Resolves a path to its full URL without sending a request (for links) |

`options` accepts `headers`, `query` (a record of query-string params; nullish values are skipped), and an `AbortSignal` via `signal`. A non-`FormData` object `body` is JSON-serialized automatically. On a `401`, the client refreshes the access token once through the host and retries the request.

The base URL and token are resolved from the environment by default. Pass overrides to the constructor when needed — for example in tests:

```ts theme={null}
const client = new RestApiClient({
  baseUrl: 'https://myworkspace.twenty.com',
  token: 'my-token',
});
```

Failed requests throw a `RestApiClientError` exposing `status`, `statusText`, `url`, and the parsed `body`:

```tsx theme={null}
import { RestApiClient, RestApiClientError } from 'twenty-client-sdk/rest';

const client = new RestApiClient();

try {
  const people = await client.get('/rest/people', {
    query: { limit: 10 },
  });
} catch (error) {
  if (error instanceof RestApiClientError) {
    console.error(error.status, error.body);
  }
}
```

## 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 theme={null}
import { defineFrontComponent } from 'twenty-sdk/define';
import {
  useUserId,
  useSelectedRecordIds,
  useFrontComponentId,
} from 'twenty-sdk/front-component';

const RecordInfo = () => {
  const userId = useUserId();
  const [recordId] = useSelectedRecordIds();
  const componentId = useFrontComponentId();

  return (
    <div>
      <p>User: {userId}</p>
      <p>Record: {recordId ?? 'No record context'}</p>
      <p>Component: {componentId}</p>
    </div>
  );
};

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 |
| `useSelectedRecordIds()` | `string[]` | All selected record IDs (empty array if none selected) |
| `useSelectedRecordsFilter()` | `RecordGqlOperationFilter` or `null` | Filter matching exactly the selected records, including under Select all |
| `useSelectedObjectMetadata()` | `FrontComponentSelectedObjectMetadata` or `null` | The object metadata associated with this front component's record context |
| `useRecordId()` | `string` or `null` | **Deprecated.** Use `useSelectedRecordIds()` instead |
| `useFrontComponentId()` | `string` | This component instance's ID |
| `useTimelineActivityId()` | `string` or `null` | The current timeline activity ID when rendering a custom timeline row |
| `useColorScheme()` | `'light'` or `'dark'` | The host UI's active color scheme (`System` is already resolved) |
| `useToolCall()` | `FrontComponentToolCall` or `null` | The AI tool call this component is rendering, when it was surfaced from a logic function's `toolTriggerSettings` |
| `useFrontComponentExecutionContext(selector)` | varies | Access the full execution context with a selector function |

### Object metadata

Use `useSelectedObjectMetadata()` to identify the object associated with a record-page widget, a custom timeline row, an object-scoped command, or a front component opened in the side panel with an object (from a command, or through `openSidePanelPage` with an `objectNameSingular`). This lets an integration select templates or other configuration for the current object without searching across objects for a record ID.

```tsx theme={null}
import {
  useSelectedObjectMetadata,
  useSelectedRecordIds,
} from 'twenty-sdk/front-component';

const TemplatePicker = () => {
  const objectMetadata = useSelectedObjectMetadata();
  const selectedRecordIds = useSelectedRecordIds();

  if (!objectMetadata) {
    return <p>Open a record to select a template.</p>;
  }

  return (
    <p>
      Select a template for {objectMetadata.nameSingular}. Selected records:{' '}
      {selectedRecordIds.length}
    </p>
  );
};
```

The result contains `id` (the workspace's object-metadata UUID), `nameSingular`, and `namePlural`. The names are metadata names, such as `person` and `people`, rather than display labels.

Object metadata can be available even when no records are selected. It belongs to this front component's context, so a side panel can expose a different object from the main page. The hook updates when that context changes and returns `null` when the object cannot be resolved, when no object context is supplied, or when running on an older host that does not provide object metadata.

## Application variables

Application variables defined in [`defineApplication()`](/developers/extend/apps/config/application) with `isSecret: false` are available inside front components via the `getApplicationVariable` utility:

```tsx src/front-components/greeting.tsx theme={null}
import { defineFrontComponent } from 'twenty-sdk/define';
import { getApplicationVariable } from 'twenty-sdk/front-component';

const Greeting = () => {
  const recipientName =
    getApplicationVariable('DEFAULT_RECIPIENT_NAME') ?? 'World';

  return <p>Hello, {recipientName}!</p>;
};

export default defineFrontComponent({
  universalIdentifier: '...',
  name: 'greeting',
  component: Greeting,
});
```

<Warning>
  Secret variables (`isSecret: true`) are **not** exposed to front components.
  They are only available in [logic
  functions](/developers/extend/apps/logic/logic-functions), which run
  server-side. This prevents sensitive values like API keys from being sent to
  the browser.
</Warning>

`getApplicationVariable` always returns a **string** (or `undefined`), regardless of the variable's declared `type`. The string is serialized consistently by type (booleans as `"true"` / `"false"`, numbers as decimal strings, arrays / objects as JSON), the same format used for logic-function `process.env` — parse it yourself (`Number(...)`, `JSON.parse(...)`, `=== 'true'`). See [Variable types](/developers/extend/apps/config/application#variable-types).

### Reading and updating variables at runtime

`getApplicationVariable` returns the values injected when the component loaded, so a value written afterwards only shows up on the next load. To read the stored values, or to write one (what a [settings menu item](#settings-menu-items) does when it saves what the user typed), go through `MetadataApiClient`:

```tsx src/front-components/app-settings.tsx theme={null}
import { MetadataApiClient } from 'twenty-client-sdk/metadata';

const APPLICATION_UNIVERSAL_IDENTIFIER = '39783023-bcac-41e3-b0d2-ff1944d8465d';

const readApplicationVariables = async () => {
  const { findOneApplication } = await new MetadataApiClient().query({
    findOneApplication: {
      __args: { universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER },
      applicationVariables: { key: true, value: true, isSecret: true },
    },
  });

  return findOneApplication?.applicationVariables ?? [];
};

const saveRecipientName = async (recipientName: string) => {
  await new MetadataApiClient().mutation({
    updateOneApplicationVariable: {
      __args: { key: 'DEFAULT_RECIPIENT_NAME', value: recipientName },
    },
  });
};

const saveMyLanguage = async (language: string) => {
  await new MetadataApiClient().mutation({
    updateMyUserApplicationVariable: {
      __args: {
        applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
        key: 'LANGUAGE',
        value: language,
      },
    },
  });
};
```

Notes:

* Your component's token is bound to your own application. `updateOneApplicationVariable` targets it, so `applicationId` is optional and naming another application is refused. Reading the `applicationVariables` of another application is refused the same way, and an application belonging to another workspace reads as not found.
* The key must be one your app declares in [`defineApplication()`](/developers/extend/apps/config/application). Writing a key that is not declared is a not found.
* Secret variables read back masked, never as the stored value. Writing them works normally.
* Reading a variable's `value`, and writing it, need the `APPLICATIONS` settings permission on both your app's role and the signed-in person's role, since the component's token is the intersection of the two. Add `SystemPermissionFlag.APPLICATIONS` to `permissionFlagUniversalIdentifiers` in your [application role](/developers/extend/apps/config/roles).
* A user variable (`scope: 'USER'`) is injected with the viewing member's value, unless it is secret. `updateOneApplicationVariable` refuses it: save it with `updateMyUserApplicationVariable`, as `saveMyLanguage` does above. It writes the viewing member's value only and refuses naming another application. It needs no `APPLICATIONS` permission, but it needs the `CONNECTED_ACCOUNTS` permission on the viewing member's role and on your app's role, if it declares one: add `SystemPermissionFlag.CONNECTED_ACCOUNTS` to `permissionFlagUniversalIdentifiers` in your [application role](/developers/extend/apps/config/roles). See [Workspace and user variables](/developers/extend/apps/config/application#workspace-and-user-variables).

### System variables

The following system variables are always available via `process.env`:

| Variable | Description |
| - | - |
| `TWENTY_API_URL` | Base URL of the Twenty core API |
| `TWENTY_APP_ACCESS_TOKEN` | Short-lived token scoped to the signed-in person's role intersected with your app's, so a component can never do more than the person looking at it |

### `TWENTY_FUNCTIONS_URL`

Twenty also injects `TWENTY_FUNCTIONS_URL` into front components and logic functions: the base URL your app's HTTP-triggered logic functions are served from.

It exists because that URL is not always the Twenty server itself. On Twenty Cloud, app routes are served on a dedicated per-workspace domain (`https://<your-workspace-subdomain>.withtwenty.com`, or the application's primary public domain when one is configured) so that app-authored responses run on their own origin rather than on the Twenty app origin. Self-hosted and local instances serve app routes under the `/s` prefix on the server itself and may not set the variable at all. Since the base URL varies per workspace and per instance, your code cannot hard-code it — the server injects the right value at runtime.

You rarely need to read it directly. Call your routes through `RestApiClient` with a `/s/`-prefixed path and the client resolves the URL for you: it strips the `/s` prefix and targets `TWENTY_FUNCTIONS_URL`, falling back to `<TWENTY_API_URL>/s` when the variable is not set. Use `resolveUrl('/s/<path>')` to get the absolute URL without sending a request, e.g. for a link. Read the variable directly only when building a URL by hand:

```ts theme={null}
const routeUrl = `${process.env.TWENTY_FUNCTIONS_URL || `${process.env.TWENTY_API_URL}/s`}/documents/generate`;
```

## Host communication API

Front components can trigger navigation, modals, and notifications using functions from `twenty-sdk/front-component`:

| 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 |
| `uploadFile(file, { fieldMetadataId, fileName? })` | Upload a `Blob` into a FILES field; resolves to `{ status: 'uploaded', file: { fileId, path, url, size, mimeType } }` or `{ status: 'failed', reason: 'invalid-params' \| 'upload-failed' }` |

Use `openCommandConfirmationModal` from `twenty-sdk/front-component` for dialogs. It accepts structured title, subtitle and button options, and resolves to `'confirm'` or `'cancel'`. Twenty owns the modal, focus management, dismissal and page scrolling. Front components must not render `Dialog`, `AlertDialog`, native `dialog` elements or native browser popovers directly. Supplying a local portal container does not make direct app-owned modality supported.

Here is an example that uses the host API to show a snackbar and close the side panel after an action completes:

```tsx src/front-components/archive-record.tsx theme={null}
import { defineFrontComponent } from 'twenty-sdk/define';
import {
  enqueueSnackbar,
  closeSidePanel,
  useSelectedRecordIds,
} from 'twenty-sdk/front-component';
import { CoreApiClient } from 'twenty-client-sdk/core';

const ArchiveRecord = () => {
  const [recordId] = useSelectedRecordIds();

  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 (
    <div style={{ padding: '20px' }}>
      <p>Archive this record?</p>
      <button onClick={handleArchive}>Archive</button>
    </div>
  );
};

export default defineFrontComponent({
  universalIdentifier: 'c9d0e1f2-a3b4-5678-cdef-789012345678',
  name: 'archive-record',
  description: 'Archives the current record',
  component: ArchiveRecord,
});
```

### Storage

`localStorage` and `sessionStorage` work as they do in a normal page, with the standard synchronous API. Your keys are scoped to your app install and the signed-in user: no other app can read them, and another user signing into the same browser starts from an empty store. Values written to `localStorage` stay on the device across reloads; `sessionStorage` lasts for the browser session.

```tsx src/front-components/note-draft.tsx theme={null}
import { useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';

const NoteDraft = () => {
  const [draft, setDraft] = useState(() => localStorage.getItem('draft') ?? '');

  const handleChange = (nextDraft: string) => {
    setDraft(nextDraft);
    localStorage.setItem('draft', nextDraft);
  };

  return (
    <textarea
      value={draft}
      onChange={(event) => handleChange(event.target.value)}
    />
  );
};

export default defineFrontComponent({
  universalIdentifier: 'd0e1f2a3-b4c5-6789-def0-890123456789',
  name: 'note-draft',
  description: 'Keeps an unsaved note on this device',
  component: NoteDraft,
});
```

Twenty stores the values on your app's behalf, so a write is applied locally right away and saved in the background. Reads never wait on the host. Nothing is synced: the values do not follow the user to another browser or machine, so use a logic function's [key-value store](/developers/extend/apps/logic/key-value-store) for anything that must survive a device change.

Writes are capped, and every limit counts characters rather than bytes: keys are at most 512 characters, a single value at most 262,144 characters, and each storage at most 1,048,576 characters per app and user. A write that breaks a limit throws a `QuotaExceededError`, like the browser API.

### Working with multiple records

Use `useSelectedRecordIds()` to handle multiple selected records. This is useful for bulk operations:

```tsx src/front-components/bulk-export.tsx theme={null}
import { defineFrontComponent } from 'twenty-sdk/define';
import { useSelectedRecordIds } from 'twenty-sdk/front-component';
import { enqueueSnackbar, closeSidePanel } from 'twenty-sdk/front-component';
import { CoreApiClient } from 'twenty-client-sdk/core';

const BulkExport = () => {
  const selectedRecordIds = useSelectedRecordIds();

  const handleExport = async () => {
    const client = new CoreApiClient();

    for (const recordId of selectedRecordIds) {
      await client.mutation({
        updateTask: {
          __args: { id: recordId, data: { exported: true } },
          id: true,
        },
      });
    }

    await enqueueSnackbar({
      message: `Exported ${selectedRecordIds.length} records`,
      variant: 'success',
    });

    await closeSidePanel();
  };

  return (
    <div style={{ padding: '20px' }}>
      <p>Export {selectedRecordIds.length} selected record(s)?</p>
      <button onClick={handleExport}>Export</button>
    </div>
  );
};

export default defineFrontComponent({
  universalIdentifier: 'd0e1f2a3-b4c5-6789-defa-012345678901',
  name: 'bulk-export',
  description: 'Export selected records',
  component: BulkExport,
});
```

Surface it with a [command menu item](/developers/extend/apps/layout/command-menu-items) restricted to record selections:

```ts src/command-menu-items/bulk-export.command-menu-item.ts theme={null}
import { defineCommandMenuItem } from 'twenty-sdk/define';

export default defineCommandMenuItem({
  universalIdentifier: 'd0e1f2a3-b4c5-6789-defa-012345678902',
  label: 'Bulk Export',
  availabilityType: 'RECORD_SELECTION',
  frontComponentUniversalIdentifier: 'd0e1f2a3-b4c5-6789-defa-012345678901',
});
```

#### Select all

When the user selects all records of a view, `useSelectedRecordIds()` returns an empty array: the selection is defined by the view's filters, search and unticked rows, and can hold far more records than fit in memory. `useSelectedRecordsFilter()` returns a filter matching exactly the selected records in every case, so query with it instead of listing IDs:

```tsx theme={null}
import { Command, useSelectedRecordsFilter } from 'twenty-sdk/front-component';
import { CoreApiClient } from 'twenty-client-sdk/core';

const MarkExported = () => {
  const selectedRecordsFilter = useSelectedRecordsFilter();

  const execute = async () => {
    if (selectedRecordsFilter === null) {
      return;
    }

    await new CoreApiClient().mutation({
      updateTasks: {
        __args: { filter: selectedRecordsFilter, data: { exported: true } },
        id: true,
      },
    });
  };

  return <Command execute={execute} />;
};
```

For large selections, pass the filter to a [logic function](/developers/extend/apps/logic/logic-functions) and process the records there, page by page.

## Public assets

Front components can access files from the app's `public/` directory using `getPublicAssetUrl`:

```tsx theme={null}
import { defineFrontComponent } from 'twenty-sdk/define';
import { getPublicAssetUrl } from 'twenty-sdk/utils';

const Logo = () => <img src={getPublicAssetUrl('logo.png')} alt="Logo" />;

export default defineFrontComponent({
  universalIdentifier: '...',
  name: 'logo',
  component: Logo,
});
```

See the [public assets section](/developers/extend/apps/config/public-assets) for details.

## Sharing dependencies across front components

By default, every front component bundles its own copy of the libraries it imports, so an app with five components ships React five times. Declare shared dependencies in your app's `package.json` to build those libraries once and have every component of the app load them from a single cached file:

```json package.json theme={null}
{
  "frontComponentSharedDependencies": [
    "react",
    "react-dom/client",
    "twenty-ui/primitives/input"
  ]
}
```

Each component then imports its dependencies exactly as before — nothing changes in your component code:

```tsx src/front-components/counter.tsx theme={null}
import { useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';

const Counter = () => {
  const [count, setCount] = useState(0);

  return <button onClick={() => setCount(count + 1)}>{count}</button>;
};

export default defineFrontComponent({
  universalIdentifier: '...',
  name: 'counter',
  component: Counter,
});
```

A few things to know:

* **One shared dependencies bundle per app.** The bundle is built from your app's own dependencies, so you keep full control of the versions you ship.
* **List the exact specifiers you import.** `twenty-ui/primitives/input` and `twenty-ui/primitives/data-display` are two entries; a package name alone does not cover its subpaths. Listing `react` automatically covers `react/jsx-runtime`.
* **Share `react-dom/client` alongside `react`.** Every component renders through `createRoot`, so leaving it out means each component still bundles React DOM.
* **The bundle is cached.** It is served under a content-hash URL with a long-lived immutable cache, so it is downloaded once and reused across all components of the app until one of its dependencies changes.
* **Components that import none of the shared packages never download it.**

## Styling

Front components support multiple styling approaches. You can use:

* **Inline styles** — `style={{ color: 'red' }}`
* **Twenty UI components** — Twenty's own component library; see [Using Twenty UI components](#using-twenty-ui-components) below
* **Emotion** — CSS-in-JS with `@emotion/react`
* **Styled-components** — `styled.div` patterns
* **Tailwind CSS** — utility classes
* **Any CSS-in-JS library** compatible with React

## Using Twenty UI components

Twenty ships its component library as the [`twenty-ui`](https://www.npmjs.com/package/twenty-ui) package. Front components can use it for buttons, tags, status pills, chips, avatars, icons, typography, and theme tokens that automatically match the workspace's light and dark theme.

<Warning>
  `twenty-ui` is still in alpha. Its version number follows the Twenty SDK
  release cycle. APIs and component behavior may change between releases.
</Warning>

### Installation

`twenty-ui` is published alongside the SDK packages with the same version. Add it to your app, pinned to the version of `twenty-sdk` and `twenty-client-sdk` you use:

```bash theme={null}
yarn add -D twenty-ui@2.42.0
```

`twenty-ui` is bundled into your front component at build time, so it only needs to be a dependency of your app — there is nothing to configure at runtime.

### Importing components

Import from the matching subpath rather than the package root, so only the components you use end up in your bundle:

Foundational controls are grouped under `twenty-ui/primitives/<family>`.
Shared design presets and app building blocks are available from `twenty-ui/components`.
These entry points do not require `monaco-editor`.

| Subpath | What it exports |
| - | - |
| `twenty-ui/components` | Shared presets, pickers, menu rows, dropdowns, toasts, and the JSON viewer |
| `twenty-ui/primitives/accessibility` | Hidden elements |
| `twenty-ui/primitives/data-display` | Avatars, chips, tags, color samples, and status indicators |
| `twenty-ui/primitives/feedback` | Banners, progress bars, and loaders |
| `twenty-ui/primitives/input` | Buttons and form controls |
| `twenty-ui/primitives/layout` | Expansion, separators, direction, and resizing |
| `twenty-ui/primitives/navigation` | Action links, list items, and tabs |
| `twenty-ui/primitives/surfaces` | Cards, menus, nonmodal popovers, and tooltips |
| `twenty-ui/primitives/typography` | Text and headings |
| `twenty-ui/icon` | `Icon*` components (e.g. `IconCheck`) |
| `twenty-ui/theme` | `ThemeProvider`, `themeCssVariables` |

Use [`Dropdown`](/ui/components/navigation/dropdown) for action menus, option pickers, and form panels.

The standalone `Dialog` and `AlertDialog` APIs are also exported by Twenty UI, but direct use in front components is prohibited. Use the [host communication API](#host-communication-api) for dialogs.

```tsx theme={null}
import { defineFrontComponent } from 'twenty-sdk/define';
import { Button } from 'twenty-ui/primitives/input';
import { Status, Tag } from 'twenty-ui/primitives/data-display';

const StyledWidget = () => {
  return (
    <div style={{ padding: '16px', display: 'flex', gap: '8px' }}>
      <Button onClick={() => alert('Clicked!')}>Click me</Button>
      <Tag color="green">Active</Tag>
      <Status color="green">Online</Status>
    </div>
  );
};

export default defineFrontComponent({
  universalIdentifier: 'e5f6a7b8-c9d0-1234-efab-567890123456',
  name: 'styled-widget',
  component: StyledWidget,
});
```

### Icons

Import individual icons from `twenty-ui/icon`:

```tsx theme={null}
import { IconBox, IconCheck } from 'twenty-ui/icon';
```

Each named icon is tree-shaken, so importing a handful adds little to your bundle. Avoid `IconsProvider` and `useIcons` in front components. Front components are bundled without code splitting, so the provider's dynamic icon catalog (the full Tabler set, roughly 2 MB minified) ends up in your bundle. See the [icon reference](/ui/icons) for available exports and supported props.

### Theming and theme tokens

Twenty UI components automatically match the workspace's light and dark theme — the renderer applies the active color scheme on the host, and the components resolve their colors against it.

To use the same design tokens in your own inline styles, call the `useTheme()` hook. It returns Twenty's theme tokens (spacing, colors, radii, fonts) wired to the active theme, with no `ThemeProvider` setup needed in your component:

```tsx theme={null}
import { useTheme } from 'twenty-ui/theme';

const Card = () => {
  const theme = useTheme();

  return (
    <div
      style={{
        padding: theme.spacing[4],
        background: theme.background.secondary,
        color: theme.font.color.primary,
      }}
    >
      Themed card
    </div>
  );
};
```

Because `useTheme()` is a hook, you read tokens inside the component body, so the values always reflect the live theme. The same token map is also exported as the `themeCssVariables` constant, but prefer `useTheme()` in front components — a module-level constant that dereferences `themeCssVariables` can be undefined while the app manifest is extracted.

To branch on the active scheme explicitly, read it with `useColorScheme()` from `twenty-sdk/front-component`, which returns `'light'` or `'dark'`.

## Current limitations

Front components are under active development. Rendering, styling, handling events, measuring elements, body portals and browser storage work well. Anything that reaches *past* those (calling a DOM method on a ref, for example) is missing or incomplete today, and most of it fails silently: no exception, and no TypeScript error either, since the scaffold is typed against the full browser DOM.

If one of these blocks you, [open an issue](https://github.com/twentyhq/twenty/issues/new/choose) so it gets prioritized.

### Layout and measurement

Elements can measure themselves: the host mirrors geometry into the sandbox, so reads are answered locally but can be up to one frame stale, and the first read of a never-measured element returns zeros. After writing, re-read in a `requestAnimationFrame` callback or an effect.

| API | What happens |
| - | - |
| `getBoundingClientRect()`, `offsetWidth`, `clientWidth`, `scrollWidth`, `offsetTop`, ... | Work, from the mirror; assigning `scrollTop` / `scrollLeft` is a no-op |
| `getClientRects()` | Throws |
| `window.matchMedia()` | Works for a fixed set of features, with `change` events (see below) |
| `window.innerWidth`, `innerHeight`, `devicePixelRatio`, `scrollX`, `scrollY` | Work, reporting the **browser viewport**; your widget's own size is `document.body.clientWidth` / `clientHeight` |
| `window.getComputedStyle()` | Host layout values for measured elements and their parents; the rest, theme variables too, from inline styles |
| `ResizeObserver` | Works, from the mirror; `document.body` reports your widget's box |
| `IntersectionObserver` | `ReferenceError` (`typeof` guards do work) |
| `MutationObserver` | Works, including `subtree`, `attributeFilter`, old values and `takeRecords()` |

Positioning from `getBoundingClientRect` and size observation through `ResizeObserver` both work from the same mirror, so resize callbacks trail the host by about a frame. Prefer CSS for layout anyway: your stylesheet reaches the real page, so flexbox, grid, `aspect-ratio`, `clamp()` and `@container` behave normally, with no frame lag.

`matchMedia()` evaluates `width`, `height`, `orientation`, `-webkit-device-pixel-ratio`, `resolution`, `hover`, `pointer` and `prefers-color-scheme`, in the `min-`/`max-` form, the Level 4 range form (`(width >= 600px)`, `(400px <= width <= 800px)`) and the valueless form (`(width)`). Widths, heights and `orientation` describe **your widget's box**, not the browser viewport, so they follow `document.body.clientWidth` rather than `window.innerWidth`, and `em` and `rem` count as 16px. `hover` and `pointer` describe the primary input of the user's device. Any other condition counts as unknown, the way browsers treat unknown features: the comma-separated member it appears in evaluates to `false`, even under `not`, unless the rest of that member already decides the result, as in `not print and (prefers-reduced-motion: reduce)`. That covers unknown features and the syntax this parser does not implement inside a condition, such as nested groups like `((min-width: 600px))`, `not` inside a group and `calc()` values. An `or` between conditions makes its member `false`. The `MediaQueryList` and `MediaQueryListEvent` constructors are not exposed either, so `instanceof` checks against them throw a `ReferenceError`.

Because that stylesheet reaches the real page, a `@media (min-width: ...)` rule is evaluated by the host against the browser viewport, while `matchMedia('(min-width: ...)')` answers for your widget's box. To make CSS follow the same box your JavaScript sees, declare your own `container-type` on a wrapper and query that: an `@container` rule without one resolves against the nearest host container, not your widget. The same split applies to color scheme, where a stylesheet `@media (prefers-color-scheme: dark)` follows the browser and OS preference while `matchMedia('(prefers-color-scheme: dark)')` follows the theme selected in Twenty, so style from `useTheme()` tokens when the two must agree.

The height you are handed is your own box as well, and depending on where the host mounts you that box can be sized by your own content. Branching layout on `min-height`, `max-height` or `orientation` can then feed back into itself and oscillate. Prefer width-based queries, or `@container` with `container-type: inline-size`, for layout decisions.

<Note>
  `requestAnimationFrame` works, with or without the `window.` prefix. Only
  Chrome runs a real animation frame inside a worker; Firefox and Safari get a
  16 ms timer, so use it to defer a re-read past a write, not to drive an
  animation.
</Note>

### Global scope

The worker's own natives are also aliased onto `window`, so they work with or without the `window.` prefix: `fetch`, `setTimeout`/`clearTimeout`, `setInterval`/`clearInterval`, `queueMicrotask`, `console`, `crypto`, `performance`, `structuredClone`, `btoa`/`atob`, `URL`/`URLSearchParams`, `AbortController`/`AbortSignal`, `Headers`/`Request`/`Response`, `Blob`, `File`, `FormData` and `TextEncoder`/`TextDecoder`.

### DOM access

A `ref` gives you a sandbox element, not an `HTMLElement`.

| What you write | What happens | Use instead |
| - | - | - |
| `ref.current.focus()`, `.blur()` | Forward to the corresponding element on the page and update the sandbox's active element | `onFocus` / `onBlur` to observe focus changes |
| `ref.current.click()` | Dispatches a `click` inside the sandbox: a checkbox or radio toggles and its `onChange` runs, but the element on the page is not clicked | Controlled components for anything the page must do |
| `ref.current.select()`, `.setSelectionRange()`, `.scrollIntoView()`, `video.play()` | Throws | Controlled components; read values from `event.target` |
| `document.createTreeWalker()` | Throws | `querySelector()` / `querySelectorAll()`, `matches()`, `closest()` (including pseudo-classes such as `:disabled` and `:not()`), `getElementById()`, `contains()` and `compareDocumentPosition()` work; `getElementsByClassName()` works too, but returns a static collection, not a live `HTMLCollection` |
| `document.activeElement` | Follows the page's focus inside the component and updates when sandbox code calls `focus()` or `blur()` | `onFocus` / `onBlur` to react to the page's focus |
| `canvas` | Renders nothing, no error | SVG, or draw offscreen and show the result in an `img` element |
| `createPortal(node, document.body)` | Renders in a host overlay layer bounded to 200px around the component | Keep overlays within this area, or add scrolling for larger content |

`element.classList` works, backed by the `class` attribute: `add`, `remove`, `toggle`, `replace`, `contains`, `item`, `length`, `value` and iteration all behave as they do in a browser, and `className` stays in sync. `element.dataset` works the same way, backed by the `data-*` attributes; write `data-*` names in lowercase, since the sandbox keeps attribute names as written instead of lowercasing them like a browser.

Body portals can extend up to 200px beyond each edge of your component, including outside a widget's scroll frame. Larger overlays are clipped to this area, so give long menus their own scrolling container. Native top-layer APIs such as `showModal()`, `showPopover()` and `requestFullscreen()` are blocked.

### Events

Mouse, pointer, touch, drag, keyboard, focus, `input`/`change`/`submit`, `scroll`/`wheel`/`contextmenu` and `animationend`/`transitionend` cross to the host, plus a few per element: `load`/`error` on `img`, clipboard and composition on `input`/`textarea`, media on `video`/`audio`, `toggle` on `details`/`dialog`. Anything else (`onAuxClick`, `onSelect`, `onInvalid`, `onReset`, `onAnimationStart`, pointer capture, `onLoad` off `img`) is dropped without warning.

Handlers receive typed events built from the page event, `PointerEvent`, `MouseEvent`, `KeyboardEvent`, `InputEvent`, `WheelEvent` or `FocusEvent`, and those constructors exist in the sandbox for `dispatchEvent()`. Each forwarded event also carries React's synthetic event surface (`nativeEvent`, `isDefaultPrevented()`, `isPropagationStopped()`, `persist()`), so code written against React events keeps working even though the sandbox hands the event object to your handler directly.

A page event crosses when the element the user interacted with, or one of its ancestors in your component, has a handler for that event type. It crosses once and then propagates inside the sandbox as it does on the page: `event.target` is the element the user interacted with, `currentTarget` is the element whose handler is running, `stopPropagation()` keeps ancestors' handlers from running, and `relatedTarget` points to the sandbox element focus or the pointer moved from or to, or is `null` when that element is outside your component. Like in React, `onFocus` and `onBlur` also run for focus changes inside the element, and `onClickCapture` and the other capture handlers run during the capture phase.

Handlers a library adds with `React.cloneElement`, such as the ones behind Base UI's `render` prop, run after the element's own handlers instead of replacing them, so passing `onClick: undefined` to `cloneElement` does not remove the element's handler. A handler on a Base UI `render` element can still call `event.preventBaseUIHandler()` to skip Base UI's own handler.

`event.target.value` and `checked` reflect the control once the browser has applied the user's change: in `input`, `change`, `click`, `blur` and `focusout` handlers, and in `keyup` handlers for `value`. In `keydown`, `beforeinput` and pointer handlers they hold the control's value and checked state as of its last edit, whatever your component listens to: an `Enter` handler reads the text typed so far, but a handler for a printable key does not see that key yet, so read live values from a controlled input or an `onChange` handler.

`document` listeners receive the events that cross from your component, but nothing that happens elsewhere on the page, so to catch shortcuts with `document.addEventListener('keydown', ...)`, put an `onKeyDown` on an element that wraps your component. `window` listeners only receive `error` and `unhandledrejection`. Pointer moves only cross over elements that have a move handler, and pointer capture stays on the page, which is why a drag stops as soon as the pointer leaves those elements. `event.preventDefault()` does not cross either; form submission, `dragover`/`drop` and link clicks are already guarded for you.

### Attributes and styling

Each element forwards its own properties to the host DOM (`href` on `a`, `src`/`alt` on `img`, `value`/`placeholder`/`disabled` on `input`, and so on), plus a common set on every element: `id`, `className`, `style`, `title`, `tabIndex`, `role`, `draggable` and any `aria-*` / `data-*` attribute (hyphenated, so `ariaLabel` is dropped). Anything outside that is silently discarded, so express custom state as `data-*`.

Component CSS, whether from `import './styles.css'`, CSS-in-JS or a `style` element, is injected into the host page's `head` **unscoped**. So class names collide with Twenty's own (prefix them, and never write bare `div { ... }` selectors), and `@media` matches the browser window rather than your widget (use `@container` with your own `container-type`). Inline `style` props are unaffected.

### Storage and network

`localStorage` and `sessionStorage` are provided by Twenty rather than the browser: the component runs in a worker at an opaque origin, so the host stores the values on your app's behalf. See [storage](#storage) for their scoping and limits. IndexedDB, cookies, the Cache API and `BroadcastChannel` remain unavailable. To persist state across devices, call a [logic function](/developers/extend/apps/logic/logic-functions) and use its [key-value store](/developers/extend/apps/logic/key-value-store).

`fetch` works, with caveats:

* Calls to the Twenty API and your app's routes are proxied by the host, so prefer [`RestApiClient`](#calling-the-twenty-rest-api). On proxied calls, `AbortSignal` and the other `RequestInit` options are dropped, and only `string` and `URLSearchParams` bodies are supported.
* Other origins leave the sandbox with `Origin: null`, so a third-party API answers only if it sends `Access-Control-Allow-Origin: *`. Call it from a logic function instead.
* `fetch('/rest/people')` is never matched to the Twenty API, because the sandbox has no page URL to resolve a relative path against.

### File selection

File inputs deliver native `File` objects with metadata and readable contents. Use `text()` or `arrayBuffer()` to read a selected file, or pass it to the `uploadFile` host function. Opening the native chooser requires calling `input.click()` within one second of a trusted click inside your component. The SDK-built React and Preact flows are verified in Chromium.

Worker-created object URLs assigned to `img.src` support previews. Revoke your URLs when replacing the image or unmounting your component. Your application owns file validation, upload progress and cancellation.

### Media capture

Declare `microphone`, `camera`, or both in the `requestedCapabilities` array passed to `defineApplication()` so these capabilities appear during installation. Approval is workspace-wide and requires a member who can manage apps.

If an installed app calls `navigator.mediaDevices.getUserMedia()` without the required access, Twenty pauses capture and shows an approval dialog. This also supports older apps without capability declarations. Members who cannot manage apps must ask a workspace administrator to approve access. Cancelling the dialog rejects the request before browser or operating system permissions are requested.

Package updates preserve existing workspace grants and do not automatically grant additional media access. Locally synced development apps receive the capabilities declared in their manifest. Once access is approved, Twenty does not ask again for each capture, but browser and operating system permissions still apply.

An orange dot in the workspace header indicates active capture. Hover over or select the dot to see the apps using the microphone or camera and stop their capture. Pending requests appear as an outlined dot with a cancellation control.

`getUserMedia()` and `MediaRecorder` run through sandbox polyfills, so standard recording code and `MediaRecorder.isTypeSupported()` work unchanged after approval. Detailed constraint objects are accepted but not forwarded. The host captures with its defaults for the requested kinds. Different applications can capture concurrently, and stopping an app affects only its own sessions. Each front component supports one pending or active capture at a time. Store a recorded `Blob` with the `uploadFile` host function.

### Other gaps

* **Drag-and-drop payloads.** Drag events fire, but `event.dataTransfer` is `undefined`.
* **Node built-ins.** `fs`, `path` and `node:crypto` fail the build, so move that work into a [logic function](/developers/extend/apps/logic/logic-functions). Web Crypto replaces `node:crypto`, and `fetch`, `TextEncoder` and `URL` are available too, see [global scope](#global-scope) — but nothing replaces `fs`: the sandbox has no filesystem.
* **`iframe`** is always re-sandboxed without `allow-same-origin`, so an embed relying on its own session renders logged out. It has no `onLoad` either.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.