Some checks failed
App Prod-Parity E2E Dispatch / dispatch (push) Failing after 10s
CD Storybook UI / build (push) Failing after 3m25s
CD Storybook UI / dispatch (push) Has been skipped
CD deploy main / website-changed-files (push) Successful in 18m8s
CI Agent Skills / changed-files-check (push) Successful in 18m9s
CI Create App E2E minimal / changed-files-check (push) Successful in 24s
CI Create App / changed-files-check (push) Successful in 26s
CI Docs / changed-files-check (push) Successful in 26s
CI Emails / changed-files-check (push) Successful in 28s
CI Example App Hello World / changed-files-check (push) Successful in 23s
CI Front / changed-files-check (push) Has been skipped
CI Example App Postcard / changed-files-check (push) Successful in 27s
CI UI / changed-files-check (push) Has been skipped
CI Utils Test / changed-files-check (push) Successful in 45s
CI Twenty Apps / discover (push) Successful in 1m3s
CD deploy main / deploy-main (push) Failing after 3s
CI Agent Skills / agent-skills-validate (push) Has been skipped
CI Create App / create-app-test (lint) (push) Has been skipped
CI Emails / emails-test (push) Has been skipped
CI Front / front-task (lingui:compile) (push) Has been skipped
CI Front / front-task (lint) (push) Has been skipped
CI Example App Postcard / example-app-postcard (push) Has been skipped
CI UI / ui-task (lint) (push) Has been skipped
CI UI / ui-task (pack-size) (push) Has been skipped
CI UI / ui-task (size) (push) Has been skipped
CI UI / ui-task (test) (push) Has been skipped
CI UI / ui-task (typecheck) (push) Has been skipped
CI Utils Test / utils-test (push) Has been skipped
CI Twenty Apps / ${{ matrix.app.name }} (push) Has been skipped
CI Emails / ci-emails-status-check (push) Successful in 2s
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Successful in 2s
CI Example App Postcard / ci-example-app-postcard-status-check (push) Successful in 3s
CI UI / ui-sb-test (push) Has been skipped
CI Utils Test / ci-utils-test-status-check (push) Successful in 2s
CI Twenty Apps / ci-twenty-apps-status-check (push) Successful in 2s
CI UI / ci-ui-status-check (push) Successful in 2s
CI Desktop / desktop (push) Failing after 1m8s
Push docs to Crowdin / Push documentation to Crowdin (push) Failing after 55s
Push translations to Crowdin / Extract and upload translations (push) Failing after 5s
Push website translations to Crowdin / Extract and upload website translations (push) Failing after 5s
CI Create App / create-app-test (test) (push) Has been skipped
CI Create App / create-app-test (typecheck) (push) Has been skipped
CI Docs / docs-lint (push) Has been skipped
CI Example App Hello World / example-app-hello-world (push) Has been skipped
CI Front / front-task (lingui:extract) (push) Has been skipped
CI Front / front-task (test) (push) Has been skipped
CI Front / front-task (typecheck) (push) Has been skipped
CI UI / ui-sb-build (push) Failing after 1m32s
CI Twenty Apps / ${{ matrix.app.name }} (${{ matrix.server-source }}) (push) Has been skipped
CI Agent Skills / agent-skills-publish (push) Has been skipped
CI Create App / ci-create-app-status-check (push) Successful in 2s
CI Agent Skills / agent-skills-remote-source (push) Has been skipped
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI E2E Main / e2e-test (push) Has been cancelled
CI Front / front-sb-build (push) Has been cancelled
CI Front / front-build (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI E2E Main / ci-e2e-main-status-check (push) Has been cancelled
CI E2E Main / notify-main-ci-failure (push) Has been cancelled
CI E2E Main / qa-scout-comment (push) Has been cancelled
CI Front / front-sb-test (push) Has been cancelled
CI Front / front-sb-screenshots (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
DPA Sub-Processor Sync / sync-subprocessors (push) Failing after 3s
AI Catalog Sync / sync-catalog (push) Failing after 2s
876 lines
41 KiB
Plaintext
876 lines
41 KiB
Plaintext
---
|
|
title: Front Components
|
|
description: Build React components that render inside Twenty's UI with sandboxed isolation.
|
|
icon: "window-maximize"
|
|
---
|
|
|
|
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 three 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** — Defined with [`defineSettingsFrontComponent()`](#custom-settings-component), the front component renders as a section inside the app's **Settings** tab, in place of the default variable configuration UI.
|
|
|
|
A front component on its own isn't reachable from the UI — you need to *surface* it. The three 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.
|
|
- **Define it with [`defineSettingsFrontComponent()`](#custom-settings-component)** — renders it as a section inside the app's **Settings** tab, in place of the default variable configuration UI.
|
|
|
|
## 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
|
|
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
|
|
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="/images/docs/developers/extends/apps/quick-action.png" alt="Quick action button in the top-right corner" />
|
|
</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) |
|
|
|
|
## 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.
|
|
|
|
## Custom settings component
|
|
|
|
To replace the auto-generated variable configuration UI in your app's **Settings** tab with your own component, define it with `defineSettingsFrontComponent` instead of `defineFrontComponent`. It takes the same [configuration fields](#configuration-fields) (except `isHeadless`, which is not accepted since a settings component always renders visible UI) and additionally marks the component as the app's settings UI.
|
|
|
|
The component renders as a section **inside** the Settings tab, not as a replacement for the whole tab. Twenty's system-managed sections — auto-upgrade, App URL, and connections — always render above it and cannot be overridden by the app.
|
|
|
|
```tsx src/front-components/app-settings.tsx
|
|
import { defineSettingsFrontComponent } from 'twenty-sdk/define';
|
|
|
|
const AppSettings = () => {
|
|
return (
|
|
<div style={{ padding: '20px' }}>
|
|
<h2>My app settings</h2>
|
|
{/* render your own configuration UI here */}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default defineSettingsFrontComponent({
|
|
universalIdentifier: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
|
|
name: 'app-settings',
|
|
description: "Custom UI for the app's Settings tab",
|
|
component: AppSettings,
|
|
});
|
|
```
|
|
|
|
Only one settings front component is allowed per app; declaring more than one fails the build. When present, the app's **Settings** tab renders this component in place of the default variable configuration UI.
|
|
|
|
## 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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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) |
|
|
| `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) |
|
|
| `useFrontComponentExecutionContext(selector)` | varies | Access the full execution context with a selector function |
|
|
|
|
## 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
|
|
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).
|
|
|
|
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 an isolated 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
|
|
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`:
|
|
|
|
| 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' }` |
|
|
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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',
|
|
});
|
|
```
|
|
|
|
## Public assets
|
|
|
|
Front components can access files from the app's `public/` directory using `getPublicAssetUrl`:
|
|
|
|
```tsx
|
|
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
|
|
{
|
|
"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
|
|
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
|
|
yarn add -D twenty-ui@2.40.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:
|
|
|
|
All existing UI components are grouped under `twenty-ui/primitives/<family>`.
|
|
The `components/` folder is reserved for higher-level app building blocks.
|
|
|
|
| Subpath | What it exports |
|
|
| --- | --- |
|
|
| `twenty-ui/primitives/accessibility` | Hidden elements and keyboard interaction helpers |
|
|
| `twenty-ui/primitives/data-display` | Avatars, chips, tags, status indicators, and display helpers |
|
|
| `twenty-ui/primitives/feedback` | Toasts, banners, progress bars, and loaders |
|
|
| `twenty-ui/primitives/input` | Buttons, form controls, pickers, and code editors |
|
|
| `twenty-ui/primitives/json-visualizer` | JSON tree viewer |
|
|
| `twenty-ui/primitives/layout` | Layout, animation, resizing, and section components |
|
|
| `twenty-ui/primitives/navigation` | Links, list items, menus, and tabs |
|
|
| `twenty-ui/primitives/surfaces` | Cards, dialogs, menus, popovers, and tooltips |
|
|
| `twenty-ui/primitives/typography` | Text, headings, labels, and typography helpers |
|
|
| `twenty-ui/icon` | `Icon*` components (e.g. `IconCheck`) |
|
|
| `twenty-ui/theme-constants` | `ThemeProvider`, `themeCssVariables` |
|
|
|
|
```tsx
|
|
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
|
|
import { IconBox, IconCheck } from 'twenty-ui/icon';
|
|
```
|
|
|
|
Each named icon is tree-shaken, so importing a handful adds little to your bundle. Avoid `IconsProvider`, `useIcons`, and `iconsState` — they pull in the full Tabler icon set (several MB).
|
|
|
|
### 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
|
|
import { useTheme } from 'twenty-ui/theme-constants';
|
|
|
|
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 and browser storage work well. Anything that reaches *past* those (calling a DOM method on a ref, observing element resizes, portaling outside your tree) 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()`, `window.matchMedia()` | Throws |
|
|
| `window.innerWidth`, `innerHeight`, `devicePixelRatio`, `scrollX`, `scrollY` | Work, reporting the **browser viewport**; your widget's own size is `document.body.clientWidth` / `clientHeight` |
|
|
| `window.getComputedStyle()` | Returns the element's inline styles only, never the host-computed cascade |
|
|
| `ResizeObserver`, `IntersectionObserver` | `ReferenceError` (`typeof` guards do work) |
|
|
| `MutationObserver` | Works, including `subtree`, `attributeFilter`, old values and `takeRecords()` |
|
|
|
|
Positioning from `getBoundingClientRect` now works, but anything watching size changes through `ResizeObserver` (recharts `ResponsiveContainer`, Floating UI's `autoUpdate`) still does not. 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.
|
|
|
|
<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()`, `.click()`, `.select()`, `.setSelectionRange()`, `.scrollIntoView()`, `video.play()` | Throws | Controlled components; read values from `event.target` |
|
|
| `document.createTreeWalker()` | Throws | `querySelector()` / `querySelectorAll()` and `getElementById()` work; `getElementsByClassName()` works too, but returns a static collection, not a live `HTMLCollection` |
|
|
| `document.activeElement` | Always `undefined` | Track focus with `onFocus` / `onBlur` |
|
|
| `canvas` | Renders nothing, no error | SVG, or draw offscreen and show the result in an `img` element |
|
|
| `createPortal(node, document.body)` | Renders nothing, while `isConnected` reports success | Overlays inline with `position: absolute`, or pass the library your own container element |
|
|
|
|
`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.
|
|
|
|
The portal gap is why Radix, Headless UI, MUI and react-select popovers render nothing by default. Most accept a container prop; point it at an element you rendered.
|
|
|
|
### 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.
|
|
|
|
`document.addEventListener()` and `window.addEventListener()` register without error and never fire, which is why a drag stops as soon as the pointer leaves the element it started on. `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.
|
|
|
|
### Media capture
|
|
|
|
`navigator.mediaDevices.getUserMedia()` and `MediaRecorder` work inside front components through sandbox polyfills, so standard recording code runs unchanged and `MediaRecorder.isTypeSupported` answers for common container/codec combinations. Detailed `getUserMedia` constraint objects are accepted but not forwarded — the host captures with its defaults for the requested kinds — and only one capture can be live at a time across applications. Store a recorded `Blob` with the `uploadFile` host function.
|
|
|
|
### Other gaps
|
|
|
|
- **File contents.** An `input` of type `file` gives your handler file metadata only, not the bytes, so `FileReader` is not available. To upload a `Blob` your code already holds — e.g. one produced by `MediaRecorder` — use the `uploadFile` host function.
|
|
- **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.
|