Compare commits

...
Author SHA1 Message Date
Charles Bochet 3e0e1ccb86 refactor(server): extract static step builders from workflow-version-step-operations
The `runStepCreationSideEffectsAndBuildStep` method mixed 12 pure/static
step builders with 5 async ones that have side effects. Extract the static
builders into a `buildStaticWorkflowStep` utility and shared constants.

| File | Before | After |
|---|---|---|
| `workflow-version-step-operations.workspace-service.ts` | 1005 | 782 |
| `build-static-workflow-step.util.ts` | — | 237 |
| `workflow-step.constants.ts` | — | 21 |

No behavior change.
2026-06-04 03:03:19 +02:00
c7365b09e2 i18n - translations (#21189)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-06-03 17:49:40 +02:00
Thomas TrompetteandGitHub ac0368e876 fix: eliminate workflow editing flicker on active-to-draft transitions (#21176)
## Before

https://github.com/user-attachments/assets/5108a9d8-2017-41d5-855c-98714cbd4237

## After

https://github.com/user-attachments/assets/0a78d1e1-354f-4f3f-8ec4-6f46517619e4

## Summary
- Fixes visual flickering/glitching in the workflow show page header and
canvas when editing an active workflow or discarding a draft
- Root cause: SSE events re-added discarded drafts to Apollo cache, and
multiple hook instances had independent state causing version
oscillation between DRAFT and ACTIVE
- Rewrites `useWorkflowWithCurrentVersion` with a module-level
`discardedDraftId` variable shared across all instances, Apollo cache
seeding in mutation callbacks, and `lastValidResult` caching to prevent
null renders

## Test plan
- [x] Open a workflow show page with an ACTIVE workflow
- [x] Drag a node to change position → verify no flicker, status shows
DRAFT smoothly
- [x] Discard the draft → verify header does NOT flicker between
DRAFT/ACTIVE, position resets cleanly
- [x] Click on manual trigger and edit settings → verify the edit works
(draft created, settings saved)
- [x] Repeat discard + edit cycle multiple times to confirm stability
2026-06-03 15:38:38 +00:00
WeikoandGitHub cc76b7bc50 Fix fields widget new field visibility (#21111)
Fixes https://github.com/twentyhq/twenty/issues/21043

## Context
Newly created fields were never added to FIELDS widgets, regardless of
the "Set fields created in the future as visible" toggle. The widget's
newFieldDefaultVisibility was null on widgets that never explicitly set
it (it was never populated at creation), so the backend skipped them and
no view field was created.

## Implementation
Keep newFieldDefaultVisibility nullable with false (not visible) as the
behavior when not provided.
The FE now reflects that properly and shows "un-toggled" when it's null
(iso with BE behavior).

The fix also ensures the value is explicitly set to true wherever it
should be:
- Set newFieldDefaultVisibility: true at every FIELDS widget creation
path (backend default record-page layout, frontend
createDefaultFieldsWidget + useTemporaryFieldsConfiguration);
- Added a 2-9 workspace upgrade command that backfills true onto
existing standard FIELDS widgets where the value is null.
2026-06-03 15:29:57 +00:00
e64e5662e5 fix(ai-chat): refresh JWT token on SSE reconnect to prevent login red… (#20176)
Closes #18928

## Problem

When a JWT access token expires while the AI chat is streaming a
response, the SSE connection drops and `graphql-sse` calls the retry
callback. The previous implementation would wait, then destroy the SSE
client but never refreshed the token. On the next connection attempt the
client reused the same expired token, eventually triggering an
`UNAUTHENTICATED` error that redirected the user to the login screen.

## Solution

Add proactive token renewal inside `useHandleSseClientConnectionRetry`
before each reconnect attempt:

- Uses a module-level `let renewalPromise` variable to deduplicate
concurrent renewal requests , the exactpattern used in
`ApolloFactory.ts`
- Calls `renewToken` via `retryWithBackoff` against the `/metadata`
endpoint
- Writes the fresh token pair into the Jotai store ,the SSE client's
`headers()` callback picks it up automatically on reconnect
- If renewal fails -> falls back to destroying the SSE client as before

## Files changed

-
`packages/twenty-front/src/modules/sse-db-event/hooks/useHandleSseClientConnectionRetry.ts`

## Notes

This addresses the two issues from the previous review:
- No `useRef`  using module-level variable instead
- CI passing  removed the `CombinedGraphQLErrors` import

---------

Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
2026-06-03 15:27:30 +00:00
Paul RastoinandGitHub 164a5b1e8d Refactor email composer (#21177)
# Introduction
Gate what connected account can be ingested in case of ai mcp user
workspace agnostic funnel to only the workspace shared connected account

Added a quick win intregration tests on seeded connected accounts ( that
wasn't covered but already protected fix impacts only the mcp )

Refactored the API slightly too

## Notice
This mean there's a breaking change in the product behavior
Whereas before a non user workspace related mcp interaction would might
have fallback on any private user connected account it will now only
search for workspace visible listed ones
2026-06-03 15:15:51 +00:00
Rashad KaranouhandGitHub 8c3a93871e fix: update yarn.lock after removing dotenv and zod from twenty-sdk (#21174)
## Summary

- Commit b330105470 removed `dotenv` and `zod` from
`packages/twenty-sdk/package.json` dependencies but did not run `yarn
install`, leaving the lockfile out of sync.
- `yarn install --immutable` in CI was failing with `YN0028: The
lockfile would have been modified by this install` on those two
packages.
- This PR just runs `yarn install` to sync the lockfile — no logic
changes.

## Root cause

[Failing CI
run](https://github.com/twentyhq/twenty-infra/actions/runs/26883193920/job/79288298420):
```
YN0028: -    dotenv: "npm:^16.4.0"
YN0028: -    zod: "npm:^4.1.11"
YN0028: The lockfile would have been modified by this install, which is explicitly forbidden.
```

## Changes

`yarn.lock` only — removes the `dotenv@^16.4.0` range and the
`twenty-sdk` metadata entries for `dotenv` and `zod`.
2026-06-03 12:23:48 +00:00
0a86a6c6db i18n - website translations (#21165)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-06-03 14:10:35 +02:00
martmullandGitHub b330105470 Fix Unzipped size must be smaller than 250Mb error (#21172)
move dev dependencies into devDependencies
2026-06-03 11:39:11 +00:00
Rashad KaranouhandGitHub c2ad3f3614 (partners): bump app version 0.3.3 -> 0.3.4 (#21167)
Forgot to bump the version when merging #21162 from `0.3.3` to `0.3.4`
2026-06-03 07:34:28 +00:00
0d9f7673c9 chore: sync AI model catalog from models.dev (#21170)
Automated daily sync of `ai-providers.json` from
[models.dev](https://models.dev).

This PR updates pricing, context windows, and model availability based
on the latest data.
New models meeting inclusion criteria (tool calling, pricing data,
context limits) are added automatically.
Deprecated models are detected based on cost-efficiency within the same
model family.

**Please review before merging** — verify no critical models were
incorrectly deprecated.

Co-authored-by: FelixMalfait <6399865+FelixMalfait@users.noreply.github.com>
2026-06-03 09:34:21 +02:00
Rashad KaranouhandGitHub 6b5a956c85 feat(website): partner marketplace UI fixes & CTA improvements (#21163)
## Summary

Partner marketplace UI fixes and CTA improvements on the website
(`twenty-website`).

## Changes

- **Find a partner buttons** → link to `/partners/list` (hero + signoff
sections) instead of opening the contact modal.
- **Chip row layout fix** → align partner chip rows to the first chip's
baseline. In the narrow 3-column grid, category chips wrap to multiple
lines; centering floated the label to the middle and visually lifted the
first chip into the row above (e.g. "Custom Development" appeared under
Languages). Baseline keeps the label pinned beside the first chip.
- **Card CTA** → replace the conditional "Book a call" calendar CTA with
a "View profile" link to the partner profile page, shown on every card
regardless of whether a calendar link exists. Keeps card heights
consistent across the grid.
- **Card avatar** → show the partner's real profile picture when a safe
`http(s)` URL is present, keeping the initials block as the fallback.
- **Profile "Contact <partner>" CTA** → when a partner has no booking
(calendar) link, show a "Contact <partner>" button (alongside LinkedIn
if present) that opens a `mailto:rashad@twenty.com` with a partner-named
subject and a pre-filled body prompt.

## Tests

- New: `PartnerAvatar`, `PartnerProfileCtas` unit tests.
- Updated: `PartnerCard` tests for the View-profile CTA.
- All `Partner*` website tests pass.

## Screenshots 
<img width="1440" height="816" alt="Screenshot 2026-06-02 at 23 40 55"
src="https://github.com/user-attachments/assets/bd88a9be-3297-4d7f-891c-c9d403d2b4d9"
/>
<img width="1441" height="818" alt="Screenshot 2026-06-02 at 23 40 47"
src="https://github.com/user-attachments/assets/b315424f-48d1-4d29-9e97-1fcf4d8c47f2"
/>
<img width="458" height="505" alt="Screenshot 2026-06-02 at 23 40 18"
src="https://github.com/user-attachments/assets/2a4e0ec2-8e0a-4220-84ec-177c788aa580"
/>
2026-06-02 20:22:41 +00:00
Rashad KaranouhandGitHub ff5d082e7c feat(partners): remove Project Budget Typical field, rework partner views & nav order (#21162)
## Summary

Partners-app changes spanning the Partner object, its data scripts,
table views, and sidebar navigation.

### Remove the "Project Budget Typical" field
Dropped the `projectBudgetTypical` currency field from the Partner
object and every reference to it:
- `get-partner-by-slug` and `list-available-partners` logic-function
selections
- the seed script (type, write mapping, and per-partner data)
- the `import-from-tft` mapping (also dropping the now-unused
`partnerBudgetAverage` TFT source selection)

`projectBudgetMin` is intentionally kept.

### Rework partner views
- **Partners** (all-partners) view: replaced the **Deployment
Expertise** column with **Categories** (the `partnerScope` field).
- **Validated partners** view: added a **Languages Spoken** column.
- Set view `position`s so the in-object view switcher orders **Validated
→ Applications → Partners**.

### Navigation order
Reordered the "Partners" folder navigation items so the sidebar reads
**Validated partners → Partner applications → Partners** (Partner
content stays last).

### Also included
The previously-pushed fix that excludes partners with an empty slug from
the available-partners list.

## Notes
- No deploy/sync performed. The view-column and navigation-ordering
changes take effect once the app manifest is synced (`yarn twenty dev
--once` locally).
- The `deploymentExpertise` field itself is unchanged — only its column
was removed from the all-partners view.
2026-06-02 20:19:07 +00:00
Thomas TrompetteandGitHub 015b5b6919 fix(twenty-server): fix AI tools description for SELECT view filter values (#21156)
Fixes https://github.com/twentyhq/twenty/issues/20840

## Summary
- Fix the AI tools factory (`view-filter-tools.factory.ts`) description
that incorrectly described SELECT filter values as plain strings,
causing agents to write values like `"CLOSED_LOST"` instead of the
required array format `["CLOSED_LOST"]`

This led to production crashes when later trying to update/delete select
options on fields that had view filters created by AI agents with
invalid format.

## Test plan
- No behavior change in existing code — only the tool description is
updated to guide AI agents correctly
- Manual: confirm AI agents now create SELECT filters with array values
2026-06-02 15:59:26 +00:00
Charles BochetandGitHub f6a17bc5f7 refactor(server): split LambdaDriver and LocalDriver into focused sub-services (#21153)
## Why

`LambdaDriver` (1.4k lines) and `LocalDriver` (709 lines) each mixed
many unrelated concerns in a single class.

## What changed

**This PR only moves code** — every line is byte-for-byte the same as
`main` (same logic, same comments, same constants, same lock keys/TTLs,
same error mapping). The drivers are now thin orchestrators wiring
co-located sub-services + pure utils (each with unit tests).

| File | Before | After |
|---|---|---|
| `lambda.driver.ts` | 1425 | 252 |
| `local.driver.ts`  | 709  | 231 |
| Largest sub-service | — | 371 (`lambda-executor-manager`) |
| Unit tests on these paths | 0 | 15 |

Lambda split: `lambda-aws-client` / `lambda-tool-functions` /
`lambda-layer-manager` / `lambda-executor-manager` + `constants` +
`types` + 5 pure utils.

Local split: `local-layer-manager` / `local-child-process-runner` /
`local-prebuilt-bundle` + `constants` + `types` + 3 pure utils.

No behavior change, no signature change, no public-API change.
2026-06-02 15:43:11 +00:00
Raphaël BosiandGitHub 1833fa84a5 Fix front component pointer/mouse event coordinates (#21117)
Fixes https://github.com/twentyhq/twenty/issues/21000

Front-component event handlers read standard event fields
(event.clientX, event.offsetX, …), but these were always undefined. On
the remote side, serialized event data was passed only as the
CustomEvent's detail — and CustomEvent ignores every constructor option
except detail, so the values lived at event.detail.clientX and never on
the event object itself.

- Added `applySerializedEventProperties`, to copy a curated allowlist of
event-level keys onto the event. Element/target state (value, checked,
files, scroll, media props) stays in
`applySerializedEventTargetProperties`, applied to this (the dispatch
element = event.target).
- Added x/y to `SerializedEventData` and to host-side serialization in
`createHtmlHostWrapper`.
- Added an `svg-pointer `story + `createHtmlTagPointerStory`

Note: Also pinned @types/react to v18 so the renderer stops dragging in
React 19 types and breaking typecheck.
2026-06-02 15:04:11 +00:00
1642be86f5 Bonapara/twenty codex plugin (#20857)
@martmull v2.0 ;)

---------

Co-authored-by: martmull <martmull@hotmail.fr>
Co-authored-by: bosiraphael <raphael.bosi@gmail.com>
2026-06-02 14:39:14 +00:00
Charles BochetandGitHub cb744b2eeb refactor(twenty-server): collapse workspace migration build orchestrator (#21144)
## Why

`WorkspaceMigrationBuildOrchestratorService.buildWorkspaceMigration` was
1117 lines of 30 near-identical copy-pasted blocks (one per metadata
entity) plus a 165-line ordered `actions: [...]` literal. Adding a new
entity meant copying ~30 lines and hoping the boilerplate stayed in
sync; the actual per-entity execution order was buried in the noise.


## Gain

- Orchestrator: **1117 → 388 lines**
- Net diff: **-558 lines**
- Adding a new entity is now a single line in the registry
- Execution order is visible at a glance
2026-06-02 14:16:10 +00:00
Anish PaudelandGitHub b422550fcc refactor(server): merge duplicate TypeOrmModule.forFeature calls in MessagingMessageCleanerModule (#21150)
Combined two separate `TypeOrmModule.forFeature()` calls into one. Both
registered entities on the default data source, so no behavioral change.
Repositories for WorkspaceEntity and MessageChannelEntity remain
injectable as before.
Cleaner imports, one less redundant call.
2026-06-02 14:10:36 +00:00
3aae4330b6 i18n - website translations (#21137)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-06-02 16:10:15 +02:00
Félix MalfaitandGitHub 1e336dbad1 feat: allow many-to-one relations as advanced filter leaves (#21147)
## What

Lets a many-to-one relation be selected as the **leaf** of an advanced
(nested) filter. Previously the nested-field submenu excluded relations,
so you could filter `Opportunities WHERE company.Name contains X` but
not `Opportunities WHERE company.accountOwner = me`.

## How it works

Selecting a relation leaf filters by its **foreign key** —
`company.accountOwnerId = X` — a single hop the backend already resolves
on the joined table (`{ company: { accountOwnerId: { in: [...] } } }`).
It is **not** a multi-hop traversal: filtering on a *scalar field of*
the related record (e.g. `company.accountOwner.name`) stays excluded,
since that needs a second join the backend caps at one hop.

Two changes:
- **`AdvancedFilterRelationTargetFieldSelectMenu`** — stop excluding
many-to-one relations from the nested-field submenu.
- **`ObjectFilterDropdownRecordSelect`** — resolve the record picker's
object from the *leaf* relation's target (e.g. WorkspaceMember,
including the "Me" pin) rather than the source relation's object. The
source-field fallback applies only when there is no leaf.

## Testing
- Added `turnRecordFilterIntoRecordGqlOperationFilter` unit cases
asserting a relation leaf (and `= me`) compiles to the FK form — 59/59.
- typecheck + lint green (twenty-front, twenty-shared).

Seeding an onboarding view that uses this filter will follow in a
separate PR.
2026-06-02 15:52:08 +02:00
c14422473b i18n - translations (#21154)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-06-02 15:36:10 +02:00
120793f69f fix: block self-impersonation in admin panel (#21130)
## Issue
- From Settings -> Admin Panel -> Workspace -> Members, impersonating
the currently logged-in user still issued an impersonation login token.
Token exchange produced invalid impersonation JWTs
(`impersonatorUserWorkspaceId === impersonatedUserWorkspaceId`). JWT
validation then failed with `User cannot impersonate themselves`,
leaving the app in an endless loading state until cookies were cleared.
- Closes #21086

## Approach
I was first thinking of to only hide the impersonate button for the
logged-in user in the admin, since they can not click what isn’t shown
(as I thought it was just a frontend issue).

But that was not enough:
- The `impersonate` mutation can still be called directly (GraphQL
client, scripts, devtools).
- Before this fix, the mutation could succeed and only fail later at JWT
validation, which led to invalid tokens and a broken session.

So the PR does both:
- Frontend: hide/disable self-impersonation in the UI and avoid
reloading on failed token exchange (UX).
- Backend: reject self-impersonation in `ImpersonationService` and at
token exchange (enforcement, fail fast before bad tokens).

Hiding the button is the right product behavior; the backend change is
what makes the rule real and safe.

## How to test
Manual:
- Log in as a user with admin impersonation.
- Go to Settings -> Admin panel -> Workspace -> open your workspace ->
members.
- Confirm your row has no Impersonate button; other members still do.
- Open Admin Panel -> User for yourself -> confirm no impersonate
button.
- Open Settings -> Members -> your own member profile -> confirm no
Impersonate action.
- Impersonate another member -> should work as before

Automated: `npx jest impersonation.service.spec`

### Before:
<img width="830" height="413" alt="Screenshot 2026-06-02 122224"
src="https://github.com/user-attachments/assets/46f38a74-8bd6-4ffa-b749-500ce18314f1"
/>

### After:
<img width="795" height="369" alt="Screenshot 2026-06-02 122333"
src="https://github.com/user-attachments/assets/62ece4a8-d38b-4f91-817f-792ff49b146b"
/>

---------

Signed-off-by: Parship Chowdhury <parshipchowdhury@gmail.com>
Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
2026-06-02 13:15:44 +00:00
Raphaël BosiandGitHub 4d520a312f Allow functional iframes in front components while blocking sandbox escapes (#21145)
Fixes https://github.com/twentyhq/twenty/issues/19899

Front component iframes were previously forced to `sandbox=""`, which
fully locks them down: no scripts, no forms, no popups. That broke any
legitimate embedded content (maps, widgets, embeds) developers tried to
render.

But we can't just trust the app-provided sandbox value either: tokens
like allow-same-origin or allow-top-navigation would let a malicious
embed escape the sandbox and hijack the host Twenty tab.

- Add `sanitizeIframeSandbox`, which keeps the iframe useful while
enforcing security:
applies a safe default (allow-scripts allow-forms allow-popups) when no
sandbox is set
always forces allow-scripts so embeds work
- strips dangerous tokens (`allow-same-origin`, all
`allow-top-navigation`*, `allow-popups-to-escape-sandbox`),
case-insensitively
- Wire it into `createHtmlHostWrapper` so every `iframe` rendered by a
front component is sanitized.
- Add unit tests for the sanitizer and Storybook interaction tests
asserting dangerous sandboxes are stripped.
2026-06-02 13:01:18 +00:00
Rashad KaranouhandGitHub ea84aabe4c chore(twenty-partners): refine design-doc skill doctrine (#21151)
## Summary

Iterative refinements to the partner design-doc doctrine after running
it on a second lead (TADA) and reviewing output side by side. Touches
only the `twenty-partner-design-doc` skill files (doctrine + Claude Code
wrapper); no runtime / app code.

**What changed**

- **Flag system:** emoji + short text label pairs only (`🔮 inf.`, **
open**, **⚠️ heavy**, **🛑 blocker**). Replaces the prior text-tag-only
system; scannable, unambiguous.
- **Section structure:** split into **Required** (always present) and
**Conditional** (Views, Automations, Integrations, Reporting). Include
conditional sections only when the client grounded them in the source.
Number sequentially, no gaps.
- **No filler placeholders:** banned `X was not named` / `left out on
purpose` lists in body sections. Unknowns belong in Open questions, not
as their own section or bullet.
- **Functional cross-refs:** every `§N` reference is now a markdown
anchor link `[§N](#n-section-slug)`, so a partner skimming the doc can
navigate. Bare `§N` is banned.
- **Bullets and tables over paragraphs**, with **Open questions** kept
as a numbered list (so the partner can read items 1, 2, 3 with the
client).
- **Views & navigation** rendered as a tight `Surface | Shows |
Audience` table. No view-type column — table / kanban / page layout is
the partner's call, not a scoping decision.
- **Data-model table** gains a `Source` column (`client` / `inf.`) for
at-a-glance fact-vs-inference visibility.
- **Business decisions over technical mechanics:** cut SDK / runtime
internals that don't move the quote (Docker version, OAuth flavour,
auto-system relations, env-var names, CI/CD workflow detail).
- **Common-mistakes table** updated with rows for the new rules.
- **SKILL.md self-check** expanded so the wrapper enforces all of the
above before saving.

## Test plan

- [ ] Re-read doctrine end-to-end for internal consistency
- [ ] Verify the four canonical emoji + text pairs appear and no stray
emoji flags remain
- [ ] Confirm Required vs Conditional structure is internally consistent
(no section listed in both)
- [ ] Confirm functional-cross-ref rule appears in both Rules and
Formatting and is reflected in the SKILL.md self-check
- [ ] Confirm Views & navigation entry mandates the three-column table
and bans a Type column
- [ ] Confirm Common-mistakes table covers each new rule
2026-06-02 12:47:56 +00:00
3a67b35486 i18n - translations (#21149)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-06-02 14:40:31 +02:00
939e0b350e i18n - translations (#21148)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-06-02 14:32:17 +02:00
Félix MalfaitandGitHub c18f8d6cf7 Always show Favorites section and add favorites via the side panel (#21087)
## What & why

The left-sidebar **Favorites** section was hidden whenever the user had
no favorites, so it was effectively undiscoverable — and personal
favorites could only be created via the record-level "Add to favorites"
action or drag-drop.

This PR:
- **Always shows the Favorites section**, with an empty-state **"Add a
favorite"** call-to-action.
- Adds a **"+" on the Favorites header that opens the same "New menu
item" side panel** the Workspace section already uses, so users can add
personal **Objects, Views, Records, Links and Folders** directly from
the sidebar.

## How

Favorites and workspace navigation are the same `NavigationMenuItem`
entity (a personal favorite simply has `userWorkspaceId` set). Rather
than build a separate favorites-only flow, the shared add/edit
side-panel subsystem is made **section-aware**
(`NavigationMenuItemSection = 'workspace' | 'favorite'`):

- a new `navigationMenuItemEditSectionState` atom records which section
the panel is operating on;
- a new `useNavigationMenuItemEditController` forks persistence — the
**workspace** section stages changes in the draft (saved on
layout-customization exit), while the **favorite** section
creates/updates/deletes personal items **immediately** with
`userWorkspaceId = current member`. This mirrors the existing
`useHandleNavigationMenuItemDragAndDrop` fork.

The existing add/edit hooks, pickers and title editors were rerouted
through the controller and a section-aware items hook, so they work for
both sections with no behavior change to the workspace flow.

**Backend: no changes** — `canUserCreateNavigationMenuItem` already
authorizes personal navigation menu items of every type for any
authenticated user.

## Decisions & tradeoffs

- **Folder button → unified "+":** the folder-only header button is
replaced by the single "+" (Folder is one of the panel's options),
matching the Workspace section. This removed the inline folder-create
code path.
- **Click-to-add only in v1:** dragging items from the panel directly
into Favorites is deferred — those drag handles are disabled in the
favorite section (the drag path is hardwired to workspace layout mode),
with a defense-in-depth no-op in the drop handler.
- **Persist-on-commit:** favorite title/URL edits hit the network once
on blur/enter, never per keystroke.
- **Personal color edits** change only the favorite's own color, never
the shared object metadata (that remains a workspace-customization
behavior).
- The change touches ~37 files because it generalizes the shared
subsystem rather than duplicating it; net diff is slightly negative
(+605 / −636).

## Testing

- `npx nx typecheck twenty-front` — passes
- `npx nx lint twenty-front` (oxlint + oxfmt) — passes
- `navigation-menu-item` unit tests — pass (incl. an updated
`computeInsertIndexAndPosition` test covering personal items)
- Manual end-to-end walkthrough still recommended before merge.
2026-06-02 14:28:10 +02:00
Félix MalfaitandGitHub 431f6ae98f feat(settings): move settings chrome into a single rounded card (#21131)
## What

Replaces `SubMenuTopBarContainer` with a settings-specific
`SettingsPageLayout` that puts the whole page chrome — breadcrumb,
centered title, actions, an optional secondary bar (tabs or wizard
step), and the 760px body — inside **one rounded card**, with
`SidePanelForDesktop` as a sibling. Title, tabs and body content share
one centered vertical axis at every card width.

Supersedes #21122. One PR, no feature flag.

## New components (`@/settings/components/layout/`)

- **SettingsPageLayout** — owns the rounded card + side-panel sibling,
`useCommandMenuHotKeys`, mobile command menu
- **SettingsPageHeader** — breadcrumb · centered title · actions in a
symmetric `1fr auto 1fr` grid (symmetric padding throughout)
- **SettingsSecondaryBar** — the secondary row, bracketed by top +
bottom borders
- **SettingsTabBar** — centered tabs reusing `activeTabIdComponentState`
+ `TabListFromUrlOptionalEffect` for URL-hash sync (does not touch the
shared `TabList`)
- **SettingsWizardStepBar** — back arrow · "N. Label" · optional
trailing slot

## Migrations

- Bulk rename across ~80 call sites (`SubMenuTopBarContainer` →
`SettingsPageLayout`); old component deleted.
- 5 tab pages (AI, APIs & Webhooks, Applications, Members, Role) + the
Data Model object-detail page render their tabs in `secondaryBar`
(object-detail keeps "See records" / "New Field" in the header actions).
- The 2 role object-level steps render the wizard step bar with working
back navigation.
- Accounts consolidated into **General / Emails / Calendars** tabs;
standalone `SettingsAccountsEmails` / `SettingsAccountsCalendars` pages
+ routes + stories removed. `SettingsPath.AccountsEmails` /
`AccountsCalendars` now resolve to `accounts#emails` /
`accounts#calendars`, so existing `getSettingsPath()` links deep-link to
the right tab via the existing hash sync — no call-site changes.

## Verification

- `nx typecheck twenty-front` and `nx lint twenty-front` both clean.
- Browser (logged-in workspace): title / tab / body / card centers align
on a single axis at multiple widths — width-invariant, so alignment
holds when the AI side panel (a sibling) shrinks the card. Rounded card
with even gaps on all four sides; tab row bracketed by two 1px lines;
no-tab pages render header → body with no lines; wizard back navigation
works; `…/accounts#emails` opens the Emails tab.

The shared `PageHeader` and `TabList` are untouched. The settings side
panel itself isn't wired to open yet — that's a follow-up PR.
2026-06-02 14:22:57 +02:00
Rashad KaranouhandGitHub e721ebe300 chore(twenty-partners): bump app version to 0.3.3 (#21140)
Bumps the `twenty-partners` SDK app version 0.3.2 → 0.3.3 so `main`
tracks what's deployed to prod.

This is the deploy version for the partner-app changes that just landed:
marketplace `partnerScope` exposure (#21126), the
`submit-partner-application` endpoint + new Partner categories +
migration (#21040), the marketplace card rebind (#21127), and the signup
wizard (#21039).

No code changes — version bump only.
2026-06-02 11:42:06 +00:00
2048efb75d fix(record-table): keep column header dropdown open after Move Left/Right (#21015)
Fixes #20999

## Summary

Fixes a UX issue where clicking **Move left** or **Move right** in the
column header
dropdown immediately closed the menu, forcing users to reopen it for
every single move.

## Problem

`handleColumnMoveLeft` and `handleColumnMoveRight` both called
`closeDropdownAndToggleScroll()` unconditionally at the top of their
handlers — before
even checking `canMoveLeft` / `canMoveRight`. This immediately set the
Jotai atom
`isDropdownOpenComponentState` to `false`, unmounting the dropdown.

Since move actions are **repeatable** — a user might want to shift a
column several
positions — they were forced into a frustrating loop: click header →
click move → click
header → click move → repeat for every step.

## Fix

Removed the two `closeDropdownAndToggleScroll()` calls from the move
handlers in
`RecordTableColumnHeadDropdownMenu.tsx`.

```diff
  const handleColumnMoveLeft = () => {
-   closeDropdownAndToggleScroll();
-
    if (!canMoveLeft) return;
    moveTableColumn('left', recordField.fieldMetadataItemId);
  };

  const handleColumnMoveRight = () => {
-   closeDropdownAndToggleScroll();
-
    if (!canMoveRight) return;
    moveTableColumn('right', recordField.fieldMetadataItemId);
  };
```

All other handlers — **Filter, Sort, Hide** — are untouched and still
close the dropdown
correctly, since those are one-shot or navigation actions.

## Changes

| File | Change |
|---|---|
| `RecordTableColumnHeadDropdownMenu.tsx` | Remove 2
`closeDropdownAndToggleScroll()` calls from move handlers |
| `RecordTable.stories.tsx` | Add `HeaderMenuStaysOpenAfterMoveRight`
regression story |

## Testing

**Storybook interaction test** — `HeaderMenuStaysOpenAfterMoveRight`:
clicks "Move right" then asserts the menu is still visible.

**Manual checklist:**
- [x] Move right → menu stays open
- [x] Move right again → column moves again, menu still open
- [x] Move left → menu stays open
- [x] Move rightmost column → "Move right" disappears, menu stays open
showing "Move left"
- [x] Filter → menu closes *(unchanged)*
- [x] Sort → menu closes *(unchanged)*
- [x] Hide → menu closes *(unchanged)*
- [x] Click outside → menu closes *(unchanged)*
- [x] Escape → menu closes *(unchanged)*
- [x] TypeScript: zero new errors (`tsc --noEmit`)

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
2026-06-02 11:23:26 +00:00
Raphaël BosiandGitHub b2539f5b6a Prevent conditional availability variables from being used at runtime (#21110)
Fixes https://github.com/twentyhq/twenty/issues/21094

Conditional availability variables (`objectMetadataItem`,
`numberOfSelectedRecords`, `objectPermissions`, operators like
`everyEquals`/`none`, etc.) are compile-time-only constructs used in
`conditionalAvailabilityExpression`. They were previously exported from
`twenty-sdk/front-component`, which let developers mistakenly import
them into runtime component code where they have no value.

- Move conditional availability variables from
`twenty-sdk/front-component` to `twenty-sdk/define`.
- Add a build-time manifest validation
(validate-conditional-availability-usage) that fails the build if these
variables are imported/used outside of
`conditionalAvailabilityExpression`.
- Update the github-connector example app to register commands via
dedicated *.command-menu-item.ts files instead of inline command config
in front components.
- Update docs (all locales) and test mocks to reflect the new import
paths.
2026-06-02 11:22:38 +00:00
2375c2f59c i18n - translations (#21141)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-06-02 13:35:06 +02:00
Charles BochetandGitHub 58907b733c feat(logic-function): add LIVE / PREBUILT execution modes (#20873)
## Summary

### Why

1. Sending the code to the lambda (~1Mb usually) is heavy on network and
results to a constant traffic of ~30Mb/s on AWS which results into TB of
network data every month
2. eval(1MB of code) is not that fast, it's heavy on memory and CPU on
lambda side

### High level

Adds two execution modes for logic functions, gated behind the new
`IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED` workspace feature flag (off
everywhere by default):

- **LIVE** (current behavior, preserved bit-for-bit): the compiled
bundle is read from object storage and shipped in every Lambda invoke
payload. Used for fast iteration in the workflow editor / Settings test
runs.
- **PREBUILT** (new): the bundle is installed onto the per-function
Lambda alongside the unified executor, and invocations carry only `{
params, env, handlerName }` — saving JSON payload egress and warm-start
`import()` cost on every call.

### Key design choices

- **Unified Lambda handler** (`constants/executor/index.mjs`) dispatches
at runtime: `event.code` present ? LIVE (write to `/tmp`, dynamic
import) : `import('./prebuilt-logic-function.mjs')`. Both code paths
always coexist on the deployment package, so the same Lambda can serve
either mode without redeploying.
- **Install runs inside the `validateBuildAndRun` migration pipeline**,
not at execute time. `Create/UpdateLogicFunctionActionHandlerService`
calls `driver.installPrebuiltBundle` when `executionMode` flips
LIVE?PREBUILT or `checksum` changes while PREBUILT, gated on
`isBuildUpToDate=true` and a fresh checksum.
- **Strict execute, no reconciliation**:
`LogicFunctionExecutorService.execute` resolves `effectiveExecutionMode`
(caller override > feature flag > entity column). For PREBUILT it asks
the driver `getInstalledBundleChecksum` (Lambda `twenty:bundle-checksum`
tag for AWS, sidecar file locally) and throws
`LOGIC_FUNCTION_PREBUILT_BUNDLE_NOT_INSTALLED` on mismatch.
- **Feature flag gates every side effect**: with the flag off the
executor forces LIVE, the action-handler install hooks bail before AWS,
and workflow activation does not flip the mode. Rollback is just turning
the flag off.

### Lifecycle

- New workflow CODE step ? `LIVE`, no install.
- Workflow activated ? build + activation flips `executionMode=PREBUILT`
? action-handler installs the bundle + sets the Lambda tag.
- Draft from active version ? duplicated logic function reset to `LIVE`.
- App install ? manifest converter sets `PREBUILT`, create-action
handler installs.
- Test runs (`executeOneFromSource`, workflow editor) pass
`executionMode=LIVE` explicitly.

### Observability

`[lambda-timing]` log lines now include `effectiveExecutionMode` and
`payloadBytes`; the action handler logs `install_duration_ms` for each
install.

## Test plan

- [x] `npx nx typecheck twenty-server` ? passes
- [x] `npx oxlint --type-aware` on all changed files ? 0 warnings, 0
errors
- [x] `npx nx test twenty-server` ? 588 suites / 5009 tests pass (no
regressions vs main)
- [x] New unit suite `flat-logic-function-validator.service.spec.ts` ?
9/9
- [x] Existing
`workflow-version-step-operations.workspace-service.spec.ts` ? 8/8
(verified the new token-based DI avoids a circular-import regression)
- [x] Snapshot for
`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY` updated
to include `executionMode`
- [x] Integration suite `logic-function-execution.integration-spec.ts`
extended to assert `executionMode=LIVE` on newly-created functions and
continues to exercise the LIVE happy path
- [ ] Manual staging rollout: flip
`IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED` per workspace, observe
`[lambda-timing]` `payloadBytes` drop + `install_duration_ms`, then ramp
in prod.
2026-06-02 11:14:39 +00:00
1ae00d6753 fix(ai): correct find-records tool description (top-level filter fields) (#21109)
## Problem

The AI `find_<object>` tool builds its input schema with
`generateFindToolInputSchema`, which **spreads field filters at the args
root** (alongside `limit`/`offset`/`orderBy`/`and`/`or`/`not`).
`tool-executor.service.ts` then maps the raw model args to a filter
with:

```ts
const { limit, offset, orderBy, ...filter } = args;
```

The zod schema is only used to generate the JSON schema *shown* to the
model (`z.toJSONSchema(...)`) — it is **never used to validate the args
coming back**. So when the model emits a bare operator where a field
name belongs, e.g. `{ ilike: "Foreman" }`, it passes straight to the
query runner, which throws and burns a retry mid-turn:

```
ERROR [FindRecordsService] Failed to find records: Object person doesn't have any "ilike" field.
ERROR [FindRecordsService] Failed to find records: Object person doesn't have any "eq" field.
```

Two contributing faults:
1. **The tool description actively misleads the model** — it says ``use
filter: { id: { eq: "record-id" } }``, a `filter` wrapper the
root-spread schema doesn't have, inviting the malformed shape.
2. **No server-side validation** — invalid root keys reach the query
runner instead of being rejected against the advertised contract.

## Fix

1. **`FindRecordsService` prunes invalid filter keys before querying.**
Using the same filter shape the tool schema advertises
(`generateRecordFilterSchema(...).filterShape`), it drops any key that
is neither a real field nor a logical operator (`and`/`or`/`not`),
recursing through `and`/`or`/`not`. A model that sends `{ ilike:
"Foreman" }` now gets a valid (empty) filter rather than an exception.
Extracted as a pure, unit-tested util `pruneFilterToAllowedKeys`.
2. **Corrected the `find_<object>` tool description** to describe the
real top-level-field shape and explicitly warn against a `filter`
wrapper and bare root operators.

## Test

`__tests__/prune-filter-to-allowed-keys.util.spec.ts` covers: valid
filters untouched, bare root operators dropped, valid siblings
preserved, `and`/`or`/`not` recursion, and non-object input.

## Notes

- Defensive for all `FindRecordsService` callers; `find_one` (`{ id: {
eq } }`) and workflow find-records pass valid filters and are
unaffected.
- Companion to #21106 (RICH_TEXT composite filters). Both surfaced from
the same `"Tom Foreman's notes"` AI-chat repro; this PR addresses the
root-level-operator half.

---------

Co-authored-by: Rich Roberts <rich.roberts@talentpipe.ai>
2026-06-02 10:58:13 +00:00
e1a00ea42f fix(twenty-front): enable text selection for display-mode fields (#21068)
## Description
This PR resolves a usability issue where scalar field values (emails,
phone numbers, dates, IDs, text, etc.) rendered in display-mode or
read-only mode in the record detail side panel could not be highlighted,
selected, or copied natively with the cursor.

## Root Cause
Both `RecordInlineCellContainer` and
`RecordInlineCellHoveredPortalContent` wrapper elements had
`user-select: none;` hardcoded in their styled-component definitions.
This styling propagated down to all nested display widgets, locking
their content and preventing native text selection.

## Changes
- Updated `StyledInlineCellBaseContainer` in
`RecordInlineCellContainer.tsx` to use `user-select: text;` instead of
`none;`.
- Updated `StyledInlineCellBaseContainer` in
`RecordInlineCellHoveredPortalContent.tsx` to use `user-select: text;`
instead of `none;`.

These changes restore natural browser text selection capabilities for
record detail widgets without altering interactive edit-mode behaviors.

## Verification
- Verified styling changes.
- Tested locally to ensure that text highlighting and copy-pasting
function correctly when dragging over read-only fields.

Closes #21056

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-06-02 10:53:00 +00:00
Rashad KaranouhandGitHub 4f47885054 feat(twenty-partners): submit-partner-application HTTP logic function (#21040)
## Summary

Adds a public `POST /partner-applications` HTTP logic function on the
twenty-partners SDK app that receives applications from the website
wizard and idempotently upserts the Partner / Person / Company graph in
the partners workspace. Also introduces the validated **Category**
taxonomy on `partnerScope` (additive, prod-safe) plus the legacy→new
migration tooling.

Companion PR (website side): #21039

### Logic function
- `defineLogicFunction({ httpRouteTriggerSettings: { path:
'/partner-applications', httpMethod: 'POST', isAuthRequired: false,
forwardedRequestHeaders: ['x-application-secret'] } })`.
- Authenticates via shared-secret header (`X-Application-Secret` ↔
`PARTNER_APPLICATION_SECRET` workspace variable). Twenty's
`isAuthRequired: true` only accepts user-session JWTs, so the handler
enforces auth itself.
- Idempotent upsert keyed on `Person.emails.primaryEmail`:
  - missing email → create Company → Person → Partner
  - existing Person, no Partner → create Company + Partner, link
- existing Person + Partner → update Partner fields; preserve
staff-owned columns (`validationStage`, `reviewed`, `ranking`,
`partnerTier`, `lastMatchAt`) by omitting them from the update
- Create-time defaults preserved on resubmit: `slug =
slugify(companyName)` ("YC Agency" → "yc-agency"), `reviewed = false`,
`partnerTier = 'NEW'`.
- Currency conversion to `{ amountMicros, currencyCode: 'USD' }` for
`hourlyRate` + `projectBudgetMin`.

### Categories (`partnerScope`) — additive, prod-safe
- Adds 5 validated category options — `ADVISORY`, `SOLUTIONING`,
`DEVELOPMENT`, `HOSTING`, `SUPPORT` — to the `partnerScope` MULTI_SELECT
**without removing** the legacy options (there is production data on
them). Field relabeled **"Categories"**. The website form only emits the
new values.
- **Migration tooling** (run deliberately, *not* in CI):
`scripts/migrate-partner-scope.ts` remaps existing records legacy→new —
dry-run by default, `MIGRATE_APPLY=1` to write, two-pass
(collect-then-apply, no mutate-while-paginating).
`scripts/partner-scope-map.ts` is the single mapping source;
`import-from-tft.ts` now routes imported scope through it so the TFT
import never re-introduces retired values. Removing the legacy options
is deferred until after the migration has run + been verified.

### applicationNotes
- New `applicationNotes` TEXT field holds the wizard's single free-text
"anything else" note (the handler passes it through directly).
`deploymentExpertise` was dropped from the handler
input/validation/builders (the column is retained for now, pending the
same migration cleanup).

### Application variable
- Declares `PARTNER_APPLICATION_SECRET` with `isSecret: true` so each
workspace sets the value via Settings → Apps → Twenty Partners →
Variables. Twenty encrypts at rest and merges the decrypted value into
the handler's `process.env` at execution time (workspace value wins over
container env).

### Code quality (from review)
- One shared `slugify` (`scripts/slugify.ts`, the import's algorithm)
used by both the handler and the import, so the `slug` identity key
can't diverge across paths.
- Unit-test tier: `vitest.unit.config.ts` (no `globalSetup`) + `yarn
test:unit`, so the pure `mapLegacyScope` test runs without a live server
(the integration suite stays server-backed).

## Demo

📹 _Screen recording of the wizard end-to-end (open → walk steps → submit
→ Partner record lands):_


https://github.com/user-attachments/assets/7458dd86-e3ff-47b5-9878-0eb134ff38e3

### Tests
- Integration tests against a local Twenty workspace:
missing-/wrong-secret auth rejections, create flow (asserts slug +
`reviewed: false` + `partnerTier: 'NEW'`), update-on-resubmit +
staff-column preservation, new category values stored,
`applicationNotes` stored, bad-input shape.
- Pure `mapLegacyScope` unit test via `yarn test:unit` (no server).

## Test plan

- [ ] Install / upgrade the app on the target workspace; set
`PARTNER_APPLICATION_SECRET` in Settings → Apps → Twenty Partners →
Variables
- [ ] `curl -i -X POST <workspace-url>/s/partner-applications -H
'X-Application-Secret: <secret>' -H 'Content-Type: application/json' -d
'{"firstName":"Test","lastName":"User","email":"test@example.com","companyName":"YC
Agency","partnerScope":["ADVISORY"],"applicationNotes":"hi"}'` →
`HTTP/1.1 201` + `{"ok":true,"created":true,"partnerId":"..."}`
- [ ] Partner record shows `name: "YC Agency"`, `slug: "yc-agency"`,
`validationStage: APPLICATION`, `reviewed: false`, `partnerTier: 'NEW'`,
`partnerScope: ["ADVISORY"]`, `applicationNotes: "hi"`
- [ ] Re-curl same email with `city: "Paris"` → `created: false`,
`Partner.city` updated, staff-owned columns untouched
- [ ] Wrong / missing secret → `200` +
`{"ok":false,"reason":"unauthorized"}`
- [ ] `yarn test:unit` green (no server); `yarn migrate:partner-scope`
dry-run lists any legacy→new remaps without writing
2026-06-02 10:35:28 +00:00
Rashad KaranouhandGitHub 7e034f711f feat(website): surface partner Categories (partnerScope) in marketplace, drop deploymentExpertise facet (#21127)
## What

Rebinds the marketplace's expertise facet from `deploymentExpertise`
(Cloud / Self-host) to **`partnerScope`** — the five partner Categories:
Advisory & Discovery · Solutioning · Custom Development · Hosting &
Infrastructure · Training & Adoption.

Moves the card chip, the profile facts row, the dropdown filter, the
`?categories=` URL param, and the API-boundary normalization onto
`partnerScope`. The standalone Cloud/Self-host facet is **dropped**
(hosting is now the `HOSTING` category), per the harmonization decision.

## Depends on

- The app exposing `partnerScope` — companion app PR #21126.
- The new `partnerScope` options + data migration — signup app PR
#21040.

## Tests

TDD red→green on: `filter-partners`, both API normalizers,
`filter-url-helpers`, `PartnerCard`, `use-filter-state`. 53/53 pass;
typecheck + lint + format clean.

## Merge order (we'll decide)

Independent diff. Suggested last of the four, after the signup PRs
(#21039 / #21040) and the app PR (#21126). Run `lingui:extract` once
after #21039 merges so the `.po` files don't conflict twice. Deploy the
app + migrate before the website ships.
2026-06-02 10:32:54 +00:00
94d2e386e8 i18n - website translations (#21135)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-06-02 12:43:26 +02:00
Rashad KaranouhandGitHub a3557373e6 feat(twenty-partners): expose partnerScope on list + by-slug endpoints (#21126)
## What

Adds `partnerScope` (the partner **Categories** multi-select) to the
output of the two public partner endpoints:
- `list-available-partners` (`/s/partners`)
- `get-partner-by-slug` (`/s/partner-by-slug`)

Additive only — `deploymentExpertise` is kept, so existing consumers
(the current live marketplace) are unaffected.

## Why

Part of the partner marketplace rework. The website marketplace
(companion branch `rk-rework-marketplace-cards`) consumes `partnerScope`
to show/filter partner Categories. The new options + migration live in
the signup app PR #21040.

## Merge order (we'll decide)

Independent diff — can merge in any order. Couplings to keep in mind:
- **Version line:** this branch and #21040 both bump the app
`package.json` version; whoever merges second re-bumps.
- **Deploy (not merge):** the partners app is deployed manually. Deploy
the final combined app (this + #21040) and run `yarn
migrate:partner-scope:prod` **before** the website is deployed.
2026-06-02 10:23:51 +00:00
d0e0e27035 [Website] Partner application wizard + logic-function handover (#21039)
## Summary

Replaces the single-screen partner-application modal with a **4-step
wizard** on the public form, and points the route's upstream at the new
`submit-partner-application` HTTP logic function in the twenty-partners
SDK app.

After design review, the Expertise step landed on the validated
**Category + Skills** model: a small set of *stable* macro categories
the partner operates in, plus a *free, semi-structured* Skills field for
the concrete things that differentiate them (React, SAP, Shopify, …).

Companion PR (partners-app side): #21040

## ⚠️ Deployment notes

Before this can ship to prod, the website worker needs a new env var:

- **Add `PARTNER_APPLICATION_SECRET`** to the deploy config at
https://github.com/twentyhq/twenty-infra/tree/main/cloudflare/website.
Without it the route returns `503` ("Partner application endpoint is not
configured.").
- The value must **match** the `PARTNER_APPLICATION_SECRET` workspace
variable set in the partners workspace UI (Settings → Apps → Twenty
Partners → Variables) — that's how the handler authenticates the
incoming `X-Application-Secret` header.
- `PARTNER_APPLICATION_WEBHOOK_URL` also needs repointing from the TFT
webhook to the logic-function URL
(`https://partner.twenty.com/s/partner-applications` or equivalent) at
the same time.

## Wizard

- 4 steps inside `Modal.Root`: **Identity → Profile → Expertise →
Commercials**. Step-dot indicator, per-step required-field gating, reset
on close. The big serif hero shows **only on step 1**; later steps use
the compact `STEP n OF 4 · NAME` strip to reclaim vertical space.
- **Profile** captures Type of team (Solo/Agency), LinkedIn, City,
Country, Languages. Country uses the searchable Select (placeholder-only
label).
- **Expertise = Category + Skills + Notes:**
- **Category** — multi-select cards over 5 macro categories (`ADVISORY`,
`SOLUTIONING`, `DEVELOPMENT`, `HOSTING`, `SUPPORT`), each with a
one-line description + examples. (Replaces the old draft `partnerScope`
enum; the backend keeps the field name — see #21040.)
- **Skills** — free tag input with a clickable suggestion row + keyboard
autocomplete (↑/↓/Enter/Esc) and "add your own". Empty by default.
- **Notes** — one free textarea (merges the former `workspaceUrl` +
`customerReferences`), reviewed manually.
- `deploymentExpertise` removed from the form (covered by the Hosting
category).
- **In-modal success view** on submit ("Thanks, / we'll be in touch!")
with a Close button — replaces the old silent close.
- Removes the partners-page "Which partner program is right for you?"
three-cards section.

## Design-system primitives

- **`Form.Select`** — searchable popup whose dropdown is **portaled to
`<body>`** (fixed, anchored to the trigger, flips up, height-capped) so
the modal's `overflow`/`transform` can't clip it; pointer events are
stopped so clicking inside it doesn't dismiss the dialog.
- **`Form.TagInput`** — optional `suggestions` prop adds the suggestion
row + autocomplete menu (used by Skills); behaviour unchanged when no
suggestions are passed.
- **`CategoryCardSelect`** — compact multi-select cards.
- `Form.MultiSelect`, `Form.Currency`.

## Validation & payload

- **Single validation source:** client and server share Zod field
schemas (`partner-application-field-schemas.ts`). The reducer validates
via those instead of hand-rolled regexes, so client and server agree by
construction (e.g. both reject non-TLD URLs).
- **Typed request body:** `buildPartnerApplicationRequestBody(state)`
returns a typed `PartnerApplicationRequest` (unit-tested);
`handleSubmit` just serializes it.
- Payload is camelCase matching the logic-function input;
`applicationNotes` replaces `workspaceUrl`/`customerReferences`.
- Auth: the upstream call carries an `X-Application-Secret` header
backed by `PARTNER_APPLICATION_SECRET` (handler-enforced — the SDK's
`isAuthRequired` only accepts user-session JWTs, not workspace API
keys). The webhook-URL env uses `z.url()` (not `z.httpUrl()`) so
`http://localhost:2020/...` dev destinations parse.

## Demo

📹 _Screen recording of the wizard end-to-end (open → walk steps → submit
→ Partner record lands):_


https://github.com/user-attachments/assets/7458dd86-e3ff-47b5-9878-0eb134ff38e3

## Tests

- **62 passing** across reducer, Zod schema, route, the new
payload-builder suite, and Form helper suites. `npx tsc` clean, `nx
lint:diff-with-main` clean, Lingui catalogs regenerated (French slots
are a follow-up).

## Test plan

- [ ] `/partners` → "Become a partner" → wizard opens on Step 1 (full
hero)
- [ ] Identity: name / work email / company → Next
- [ ] Profile: pick **Type of team**; search country ("fra" → France);
pick languages → Next (compact header from here on)
- [ ] Expertise: select 1+ **Category** cards; add **Skills** (click a
suggestion, type one + Enter, drive the ↑/↓ autocomplete); optionally
fill **Notes**
- [ ] Country dropdown opens without being clipped by the modal, and
clicking inside it does **not** close the wizard
- [ ] Commercials → Submit → **in-modal "Thanks, we'll be in touch!"**;
Network shows POST `/api/partner-application` `200`
- [ ] Partner record lands with the chosen categories in `partnerScope`,
plus `skills`, `applicationNotes`, `slug` from company, `reviewed:
false`, `partnerTier: 'NEW'`
- [ ] Re-submit same email + different city → Partner updates;
`validationStage`/`reviewed`/`partnerTier` preserved
- [ ] Back/Next preserves entered values; Reset on close; mobile
single-column / chips wrap

---------

Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
2026-06-02 10:23:31 +00:00
Paul RastoinandGitHub 6a908b7876 Front component s3 redirect (#21116)
# Introduction
Unload the server of the file stream when possible
Also fix inconsistent pipeline exception management

Needs to highly be QA, not sure how the cors will behave here
2026-06-02 09:35:58 +00:00
445c6fe9f6 feat: expose CURRENCY field settings (format/decimals) in shared types (#21090)
## What

Add a `CURRENCY` entry to `FieldMetadataSettingsMapping` (a
`FieldMetadataCurrencySettings` type of `{ format?: 'short' | 'full';
decimals?: number }`) so `FieldMetadataSettings<CURRENCY>` resolves to
the real settings shape instead of `null`.

## Why

The currency **format** (Short/Full) and **decimals** selectors already
ship in the field settings UI and persist through the generic `settings`
jsonb column — they render via
[`CurrencyDisplay.tsx`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-front/src/modules/ui/field/display/components/CurrencyDisplay.tsx)
reading `settings.format` / `settings.decimals` (added in #12542 and
#16439).

But `twenty-shared` never got a `CURRENCY` entry in the settings
mapping, so `FieldMetadataSettings<CURRENCY>` is `null`. The SDK's
`defineField` derives its types from this mapping, so an app author
cannot set these from code — `universalSettings: { format: 'full',
decimals: 2 }` on a CURRENCY field is a type error, even though the
server stores and the frontend honours it. This aligns the type layer
with the already-shipped runtime behaviour.

## Changes

- `twenty-shared`: add `FieldMetadataCurrencySettings` +
`FieldCurrencyFormat`, wire the `CURRENCY` mapping entry, export
`FieldCurrencyFormat`.
- `twenty-server`: move `CurrencyFieldMetadata` from the
`NotDefinedSettings` assertions to a defined-settings assertion in the
field-metadata entity type test.

No runtime change — the server already accepts and stores these settings
via the generic jsonb column; this only makes them visible to the type
system and the SDK.

## Test plan

- [ ] `npx nx typecheck twenty-shared` / `twenty-server` pass
- [ ] In an app, `defineField({ type: FieldType.CURRENCY,
universalSettings: { format: 'full', decimals: 2 }, ... })` type-checks
and deploys
- [ ] Field renders with 2 decimals in full format, matching the
equivalent UI configuration

> Follow-up (not in this PR): the frontend keeps its own local
`fieldMetadataCurrencyFormat` / `FieldCurrencyFormat`; it could import
the shared `FieldCurrencyFormat` to de-duplicate.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 11:42:28 +02:00
neo773andGitHub 1ba7a3fa54 fix: lowercase OAuth handle to prevent duplicate connected accounts (#21120)
Google and Microsoft connect flows stored the provider-returned email
verbatim, so a re-auth with different casing (Sam@ vs sam@) missed the
existing-account lookup and created a duplicate. Normalize the handle to
lowercase at extraction, matching the SSO controller.

Note: not doing backfill for now
2026-06-02 09:27:10 +00:00
Félix MalfaitandGitHub 7d7f32b243 docs: remove the self-host cloud providers page (#21134)
## What

Removes the community-maintained **"Other methods"** cloud-providers
page from the self-host docs (it covered Kubernetes/Terraform/Coolify
community deployments).

## Changes

- **Deleted** `developers/self-host/capabilities/cloud-providers.mdx`
and its 13 localized copies (ar, cs, de, es, fr, it, ja, ko, pt, ro, ru,
tr, zh).
- **Removed the slug** from `navigation/base-structure.json` (the source
of truth) and regenerated the derived files via the repo's own
generators (`yarn docs:generate`, `yarn docs:generate-paths`):
  - `docs.json` — nav entries dropped for every locale.
- `twenty-shared/.../DocumentationPaths.ts` —
`DEVELOPERS_SELF_HOST_CAPABILITIES_CLOUD_PROVIDERS` constant dropped
(was unused elsewhere).
- **Removed the "Cloud Providers" card** from the `self-host` overview
pages across all locales.
- **Dropped the dangling redirect**
`/developers/self-hosting/cloud-providers` (its destination no longer
exists).
- Cleared the matching entry from the unused `navigation-schema.json`
for consistency.

Net: 68 line deletions across config (pure removal); no insertions.

## Verification

- `grep` confirms **0** remaining references to `cloud-providers`
anywhere in the repo.
- All touched JSON files parse; `oxlint` on twenty-docs reports 0
errors.
- Generators (not hand edits) produced `docs.json` and
`DocumentationPaths.ts`.

> Note: `mintlify broken-links` can't run to completion on this branch
due to a **pre-existing** MDX parse error in the unrelated
`l/ar/.../contribute/contribute.mdx`; the grep above is the equivalent
guarantee that no link points at the removed page.
2026-06-02 11:17:37 +02:00
5f0096c464 i18n - website translations (#21088)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-06-02 10:45:36 +02:00
6ac797a69c fix: REST cursor encoding for nested order_by composite fields (#20974)
## Summary

fix: REST cursor encoding for nested order_by composite fields

Closes #20109

---
AI was used for assistance.

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
2026-06-02 07:10:20 +00:00
3d6bcc3102 i18n - translations (#21128)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-06-02 07:30:02 +02:00
Félix MalfaitandGitHub 75df1f3997 chore(settings): address review comments from PR 21072 (#21121)
## Summary

Round through bosiraphael's 31 review threads on the merged PR #21072
(discovery hero + ephemeral playground token). The user asked to apply
each suggestion only where it adds value, so this PR is split into three
buckets.

### Comments (~17 threads)

- Tightened security-rationale / CSS-gotcha / API-doc comments to one or
two factual lines
- Kept (shortened) the comments above `RequireAccessTokenGuard` call
sites — without them a future reader could remove the guard and silently
reopen the escalation hole
- Kept (shortened) the in-memory-only rationale on
`playgroundApiKeyState` for the same reason
- Kept `flex: 1 + min-height: 0` CSS gotcha on `SubMenuTopBarContainer`
— non-obvious and easy to break

### Structure / extraction

- Move `WEBHOOK_TABLE_ROW_GRID_TEMPLATE_COLUMNS` to its own constants
file (one-export-per-file)
- Split `SettingsAgentToolsTab` and `SettingsAgentToolsTable` across
queries/, hooks/, types/, utils/:
  - `graphql/queries/findManyApplicationsForToolTable.ts`
  - `graphql/queries/findManyMarketplaceAppsForToolTable.ts`
  - `hooks/useSettingsAgentToolsTable.ts` (data loading + index merging)
  - `types/SettingsAgentToolItem|Application|MarketplaceApp`
  - `utils/getToolApplicationId|getToolLink`
- Extract `SettingsAiModelsTab` optimistic mutations into
`hooks/useSettingsAiModelsActions` (handleModelFieldChange,
handleUseRecommendedToggle, handleModelToggle,
handleToggleAllVisibleModels)
- Extract `SettingsAI.handleCreateTool` into `hooks/useCreateTool`
- Drop unnecessary `useMemo` wrappers on `heroTabs` arrays
(SettingsObjects, SettingsLayout)
- Simplify `MenuItemToggle` handler in SettingsAgentSkillsTab:
`onToggleChange={setShowDeactivated}` (no longer wrapping with arrow +
read of stale `!showDeactivated`)

### Hero assets

- Replace placeholder `customize-illustration` with per-page exports
- Rename `layout/customize-illustration-{light,dark}.png` →
`layout/cover-{light,dark}.png`
- Add `cover-{light,dark}.png` for **applications** and **members**
(they were both pointing at the layout placeholder as a TODO)
- Overwrite `data-model/cover-*.png`, `playground/cover-*.png`,
`ai/ai-tools-cover-*.png` with the new exports

## Test plan

- [ ] `npx nx typecheck twenty-front` 
- [ ] `npx nx typecheck twenty-server` 
- [ ] `npx nx lint twenty-front`  (oxlint + oxfmt, 0 warnings/errors)
- [ ] `/settings/layout`, `/settings/data-model`,
`/settings/applications`, `/settings/ai`, `/settings/api-webhooks`,
`/settings/members` each render the new hero illustration (light + dark)
- [ ] AI tab: tool list still loads, search + Custom/Managed/Standard
filters still work, "New Tool" still navigates to detail
- [ ] AI tab: Models tab — smart/fast model select, "Use best models
only" toggle, per-model checkboxes, toggle-all all still
optimistic+revert on error
- [ ] Skills tab: "Deactivated" toggle still flips show/hide
- [ ] Webhooks table still uses the 1fr 28px grid
2026-06-02 07:23:14 +02:00
Paul RastoinandGitHub d6b3527552 Public assets server s3 redirection (#21108)
# Introduction
Avoid overloading the server on file streaming
Take profit of the different origin implied by the redirection to the s3
Only concern being the expiration date on a public file which is
acceptable

closes https://github.com/twentyhq/private-issues/issues/483
related https://github.com/twentyhq/private-issues/issues/491
2026-06-01 16:25:58 +00:00
Paul RastoinandGitHub 989b45db15 Strictly type encryption rotation key site maps constants through entity type derivation (#21085)
# Introduction
Followup https://github.com/twentyhq/twenty/pull/21001

Now that the typeorm entities provide grains over their
`encryptedString` value, we can strictly type the sitemaps of the
encrypted string to rotate in case of encryption key rotation and also
the integration tests tests cases
2026-06-01 15:25:58 +00:00
WeikoandGitHub d86e827563 fix: return proper FORBIDDEN GraphQL errors from ApiKeyResolver (#21107)
## Context
CI is broken on main, regression introduced in
https://github.com/twentyhq/twenty/pull/21072

Guard-rejected ApiKey mutations returned malformed GraphQL responses.
RequireAccessTokenGuard (and SettingsPermissionGuard) throw plain
AuthException/PermissionException classes, which are not GraphQLErrors.
ApiKeyResolver had no @UseFilters, so these exceptions were never
translated, they surfaced as request-level errors with no data key
(data: undefined) and a non-FORBIDDEN code, instead of data: null +
FORBIDDEN.

This broke the `createApiKey › should reject a non-ACCESS token even
with API key permission` integration test
(expect(res.body.data).toBeNull() received undefined). The sibling
generateApiKeyToken test passed only because it lives on AuthResolver,
which already declares these filters.

## Fix
Add the standard exception filters to ApiKeyResolver, matching the idiom
used by other guard-protected resolvers
```ts
@UseFilters(AuthGraphqlApiExceptionFilter, PermissionsGraphqlApiExceptionFilter)
```
2026-06-01 18:08:02 +02:00
Paul RastoinandGitHub 6ad6fcce0f Bump playwright (#21113)
Playwright installation is infinite looping in the ci
seems like to be a global outage
2026-06-01 18:06:55 +02:00
ad47b2972c i18n - translations (#21112)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-06-01 17:42:34 +02:00
ba493e5a23 fix: show relation field changes in the timeline (#21052)
## Summary
- Fixes #20970 
- When we changed only a relation field (e.g. company on a person):
- The diff builder skipped all RELATION fields -> empty diff -> no
timeline row.
- If we change company and something else, only the scalar field
appeared; the company change was missing.
- Even with a diff, the UI validated keys by field name (`company`)
while join columns (`companyId`) could be filtered out.

## Solution
- For `MANY_TO_ONE`, compare join column values (`companyId`) and store
the diff under the relation field name (`company`):
```
"company": {
  "before": { "id": "<old-id>" },
  "after": { "id": "<new-id>" }
}
```
- Frontend
- `filterOutInvalidTimelineActivities`: resolve diff keys by field name
or join column via `findFieldMetadataItemByDiffKey`, so relation diffs
are not stripped.
- `EventRelationFieldDiffValues`: resolve related record labels by id;
show only the new value in the row (same pattern as other fields:
Company -> airSlate).
- Tooltip (relations only): on hover, show before -> after with readable
names (e.g. Microsoft -> Apple). Scalar and composite fields (e.g.
Updated by) are unchanged and do not get this tooltip.
- `EventFieldDiff`: route RELATION diffs to the relation renderer; all
other field types keep the existing FieldDisplay behavior.

### What you’ll see
On a person (or opportunity) timeline after changing company:
```
You updated Company → airSlate
(tooltip: Microsoft → Apple)
```

## Test plan
- Change only company on a person -> timeline shows a company update
with names.
- Change company and name in one save -> both appear in the diff.
- Clear company -> row shows Empty; tooltip reflects previous -> empty
if applicable.
- Same we can do for the Opportunities also
- Scalar / ACTOR fields (e.g. Updated by) - no new tooltip; display
unchanged.

## Screenshots

### Before
<img width="527" height="124" alt="Screenshot 2026-05-29 155123"
src="https://github.com/user-attachments/assets/e067f19a-8184-4e50-9cd0-9135e06188b8"
/>
<br><br>
<img width="535" height="181" alt="Screenshot 2026-05-29 155149"
src="https://github.com/user-attachments/assets/3c513a1e-c5c5-4cff-ae1c-5fed62837798"
/>
<br><br>

### After
<img width="564" height="200" alt="Screenshot 2026-06-01 154539"
src="https://github.com/user-attachments/assets/459ad6b4-af4c-4f7a-b749-30762c979627"
/>
<img width="567" height="187" alt="Screenshot 2026-06-01 154555"
src="https://github.com/user-attachments/assets/6bc4711c-afca-43d8-b874-f51fc0f374df"
/>
<img width="563" height="188" alt="Screenshot 2026-06-01 154639"
src="https://github.com/user-attachments/assets/f275b9fd-a1c3-4c4b-9538-8042657eb593"
/>
<img width="556" height="180" alt="Screenshot 2026-06-01 154654"
src="https://github.com/user-attachments/assets/2e614368-8fd7-4c73-8876-2223f2f98e67"
/>
<img width="560" height="237" alt="Screenshot 2026-06-01 154802"
src="https://github.com/user-attachments/assets/0437aef4-7b2c-4d35-a2e9-f617b90a1beb"
/>

---------

Signed-off-by: Parship Chowdhury <parshipchowdhury@gmail.com>
Co-authored-by: martmull <martmull@hotmail.fr>
2026-06-01 14:34:54 +00:00
Thomas TrompetteandGitHub 627b488556 Fix else branches not properly skipped in nested if/else workflows (#20938)
## Summary

- Extract `findParentSteps` utility that recognizes IF-ELSE steps as
parents of their branch children (via
`settings.input.branches[].nextStepIds`), used in all parent detection
sites (`shouldSkipStepExecution`, `shouldExecuteStep`,
`shouldFailSafely`, and their iterator variants)
- Centralize next-step resolution in `getNextStepIdsToExecute` via
extracted `getNextStepIdsForIterator` and `getNextStepIdsForIfElse`
utils — Iterator now properly returns loop children as
`nextStepIdsToSkip`/`nextStepIdsToFailSafely` when skipped
- Refactor `skipAndFailSafelyStepsThenContinue` to delegate to
`getNextStepIdsToExecute` instead of duplicating type-specific
propagation logic

Fixes #20934

## Test plan

- [x] New unit tests for `findParentSteps` (7 tests covering IF-ELSE
branch parent detection)
- [x] New IF-ELSE-specific tests added to `shouldSkipStepExecution`,
`shouldExecuteStep`, `shouldFailSafely` test suites
- [x] Updated Iterator skip/fail-safely tests in
`workflow-executor.workspace-service.spec.ts`
- [x] All 300 workflow executor tests pass
- [x] `lint:ci` passes
2026-06-01 17:03:50 +02:00
WeikoandGitHub b9e5ff2065 fix: broadcast timeline activities to live SSE subscriptions (#21104)
## Context
Timeline activities never updated in real time. They were explicitly
excluded from the database-event pipeline
(formatTwentyOrmEventToDatabaseBatchEvent early-returned for the
timelineActivity object), so no SSE event was ever broadcast, and the
frontend timeline only refreshed on mount/manual refetch.

## Implementation
Backend
- Feat: Stop dropping timeline-activity events in
formatTwentyOrmEventToDatabaseBatchEvent.
Instead route them through EntityEventsToDbListener, which publishes
them directly to live subscriptions (but still skipping webhook/audit
handling).
- Fix: Harden ObjectRecordEventPublisher: wrap nested-relation
enrichment in try/catch so a failure broadcasts the event without
relations instead of dropping it (logs a warning).
- Fix: Skip unreadable relation targets in CommonSelectFieldsHelper when
the role lacks canReadObjectRecords, preventing errors while computing
selected fields.
- Fix: Support MORPH_RELATION alongside RELATION in RLS row-level
permission predicate matching (timeline activities use morph targets).

Frontend
- Feat: useTimelineActivities now registers the timeline query with the
SSE system via useListenToEventsForQuery and refetches on incoming
timeline-activity record operations.
- Feat: Add a skip option to useListenToEventsForQuery so the listener
isn't registered when the object has no timeline field.

## Test


https://github.com/user-attachments/assets/ed1d1c66-d6ea-434d-ac9c-9b83d2b78338


Note: "UpdatedBy" seems to be listen to and visible in the timeline
activity summary, this is probably a bug that we want to fix
2026-06-01 16:32:08 +02:00
EtienneandGitHub 381ca32055 Billing - Fix credit upgrade invoice error (#21097)
In createImmediateUpgradeInvoice, the invoice is finalized with
auto_advance: true, which causes Stripe to automatically attempt payment
asynchronously. Then the explicit stripe.invoices.pay(invoice.id) call
races against that auto-payment — if Stripe already paid it, this throws
"Invoice is already paid".

The fix is to finalize with auto_advance: false and keep the explicit
pay call
2026-06-01 13:55:06 +00:00
nitinandGitHub 6029847491 fix navigation item tree breadcrumb active state (#21101)
before - 



https://github.com/user-attachments/assets/9a35e07c-def3-47eb-aab4-0bdcaf302d38



after - 


https://github.com/user-attachments/assets/bf452b5a-7a31-4838-83ca-27cae598ef4d
2026-06-01 13:53:42 +00:00
MarieandGitHub 66afd5a1de Fix array-typed parameters in code/logic-function action forms (#21102)
## Problem

`any[]` type prevented value input:
<img width="544" height="349" alt="Screenshot 2026-06-01 at 14 08 47"
src="https://github.com/user-attachments/assets/956238d0-6fea-4be1-b75a-ab0e6e6424ac"
/>

In the workflow Code action (and the Logic Function action), parameters
typed as any[], string[], etc. rendered as an empty grey box instead of
an "Enter value" text input. After any debounced save, even a properly
initialised array field would also collapse into an empty container.

The Array<T> / ReadonlyArray<T> generic form fell through to a generic
text input by accident (which "looked" right, but for the wrong reason —
no schema info downstream).

## Root causes
Three places treated arrays as plain objects via @sniptt/guards'
isObject (which is true for arrays):

1. WorkflowEditActionCodeFields.tsx — arrays went into the nested-fields
branch; Object.entries([]) is empty → empty container, no placeholder.
2. mergeDefaultFunctionInputAndFunctionInput.ts — recursed into arrays
during merge, turning [] into {}. Triggered on every debounced save, so
the bug surfaced after any edit.
3. get-function-input-schema.ts — only handled T[]
(SyntaxKind.ArrayType); Array<T> (SyntaxKind.TypeReference) was
unrecognised, so the form lost any item-type info.
2026-06-01 13:14:46 +00:00
da5e1152bb i18n - translations (#21103)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-06-01 14:24:47 +02:00
Félix MalfaitandGitHub b338a7a1d2 feat(settings): discovery hero rollout + ephemeral playground token (#21072)
## Summary

Two intertwined streams of work:

### UI — discovery hero pattern, settings shell, AI/API redesign
- **Generalize `SettingsDiscoveryHeroCard`** and use it on Layout, Data
Model, Apps, AI, API/Webhooks, Members. Drops 4 per-page wrapper files
(`SettingsObjectCoverImage`, `SettingsLayoutCoverImage`,
`SettingsLayoutCustomizeVideoModal`,
`SettingsDataModelVisualizeVideoModal`). Each page now supplies cover
src, modal id, and tab list.
- **Modal**: swap `<video>` placeholder for the Vimeo iframe pattern
from `twenty-docs`, per-tab `vimeoId`. Drop the parallel border-bottom
on the header (TabList draws its own baseline) and the grey background
behind the video. Note: Vimeo's embed allowlist applies — the iframes
load with the correct URL on `localhost` but the player itself requires
the video owner to allow the dev/staging domains in Vimeo settings.
- **AI page** rebuilt into a Cockpit pattern (Overview / Models / Skills
/ Tools / Usage). New `SettingsAiOverviewTab` with default Smart/Fast
pickers, at-a-glance stats, and an MCP signpost that deep-links to
`/settings/api-webhooks#mcp`. System Prompt link moved under Models.
Advanced tab removed.
- **API & Webhooks** now has 4 tabs (Playground / MCP / API Keys /
Webhooks). Hero card above tabs. Playground tab inverted to "Core API" /
"Metadata API" sections, each containing REST + GraphQL cards — schema
is the meaningful axis, protocol is secondary. Hash deep-link sync
delegated to the shared `TabListFromUrlOptionalEffect`.
- **Settings shell**: unified drawer outer padding (kill `isSettings`
branch), extract `CollapsibleNavigationDrawerSection`, add `iconColor`
on settings nav items, fix Exit Settings button alignment, 880px content
cap.

### Backend — strategy C: ephemeral playground token
The legacy paste-your-API-key flow is replaced by an on-demand
short-lived token scoped to the calling user's permissions. No shared
"Playground" API key to manage or revoke.

- New `JwtTokenTypeEnum.PLAYGROUND`. `PlaygroundTokenJwtPayload =
Omit<AccessTokenJwtPayload, 'type' | impersonation fields>` so any
future ACCESS claim flows through automatically.
- `AccessTokenService.generatePlaygroundToken` signs an access-shaped
JWT with `type: PLAYGROUND` and a configurable short TTL. A shared
private `resolveTokenSubject` helper parallelizes the user / workspace /
userWorkspace lookups for both generators.
- `JwtAuthStrategy.validateAccessToken` widened to accept
`AccessTokenJwtPayload | PlaygroundTokenJwtPayload`; impersonation gated
on `payload.type === ACCESS` so the union narrows without `as unknown
as` casts. The two branches in `validate()` collapse into one.
- New `PLAYGROUND_TOKEN_EXPIRES_IN` config var (default `2h`).
- New `generatePlaygroundToken` mutation (`WorkspaceAuthGuard`, no args,
returns `AuthToken`).
- Frontend `useOpenPlayground` hook centralizes mint → atom write →
navigate, with Apollo `onError` snackbar and a "use cached PLAYGROUND
token if still fresh" short-circuit (decodes via `jwt-decode`, checks
both `type` AND `exp`). Old API_KEY tokens left in localStorage from the
prior paste-form flow are rejected on `type` alone and force a re-mint —
this is what was causing the "This API Key is revoked" symptom on stale
browsers.

### Drive-by cleanups
- `PlaygroundToken` DTO removed (identical shape to `AuthToken` already
in use).
- 5 `customize-sidebar.webm` imports and the dead placeholder pipeline
removed.

## Test plan

### Discovery hero
- [ ] `/settings/layout`, `/settings/data-model`,
`/settings/applications`, `/settings/ai`, `/settings/api-webhooks`,
`/settings/members` each render the discovery hero card with its
illustration + play button + tabbed modal
- [ ] Modal tabs show the correct Vimeo embed URL per tab; aspect ratio
stays at 1440/900; no parallel border-bottom jog at the tab baseline
- [ ] AI Overview tab shows Smart/Fast model pickers + stats grid + MCP
signpost card; the MCP card lands on `/settings/api-webhooks#mcp` with
the MCP tab active

### API playground (ephemeral token)
- [ ] With an empty `playgroundApiKeyState` in localStorage, clicking
REST or GraphQL playground card opens the playground and the cached
token has `type: "PLAYGROUND"` with ~2h exp
- [ ] Clicking the card again within the freshness window does **not**
re-mint (`iat` / fingerprint stable across visits)
- [ ] Planting a fake API_KEY-shaped JWT in localStorage and clicking
the card forces a fresh mint (old token rejected on `type`)
- [ ] `GET /rest/companies?limit=1` with the cached token returns 200 +
real data
- [ ] `POST /graphql { __typename }` returns 200

### Settings shell
- [ ] Settings nav matches main app drawer padding; sections collapse;
Exit Settings button aligns with the workspace links above
- [ ] Active nav items have a right-gap (cleaner active state)
- [ ] Content area capped at 880px

### Verify
- [ ] `npx nx typecheck twenty-front` passes
- [ ] `npx nx typecheck twenty-server` passes
- [ ] `npx nx lint:diff-with-main twenty-front` passes
- [ ] `npx nx lint:diff-with-main twenty-server` passes
2026-06-01 14:16:02 +02:00
nitinandGitHub 6e00a122c6 fix(kanban): contain checkbox hover reveal within card bounds (#21100)
follow up to https://github.com/twentyhq/twenty/pull/20455

before - 


https://github.com/user-attachments/assets/e5a8a328-81ec-4dc4-8e54-1a54cf252135


after - 



https://github.com/user-attachments/assets/61cbb856-564c-487f-81e5-e27adc4a0d2d
2026-06-01 12:20:40 +02:00
4dff30f676 Twenty server:Fix REST pagination issues (#20980)
Fixes #20109 

The entry was repeating because in the database we store DateTime fields
with microsecond precision (timestamptz), but when JS parses timestamptz
into a Date object it only keeps millisecond precision.

### Example
If previous cursor was:
```
{
    name: "Quick Lead",
    createdAt: "2026-05-21T15:33:00.708Z",
}
```
The resulting query look something like:
```
...
WHERE (
  "workflow"."name" > "Quick Lead"
  OR (
    "workflow"."name" = "Quick Lead"
    AND "workflow"."createdAt" > "2026-05-21T15:33:00.708Z"
  )
  OR (
    "workflow"."name" = "Quick Lead"
    AND "workflow"."createdAt" = "2026-05-21T15:33:00.708Z"
    AND "workflow"."id" > "8b213cac-a68b-4ffe-817a-3ec994e9932d"
  )
)
```
So, when comparing the 2nd condition the `"workflow"."createdAt" >
"2026-05-21T15:33:00.708Z"` would always result to true because in db
the data for createdAt is `2026-05-21 21:03:00.708 +0530` which will
always be greater than `2026-05-21T15:33:00.708Z`
The second condition `"workflow"."createdAt" >
"2026-05-21T15:33:00.708Z"` always evaluates to true, because the value
actually stored in the DB for createdAt is something like `2026-05-21
21:03:00.708264 +0530`, which is always greater than
`2026-05-21T15:33:00.708Z` in the cursor. The row used to generate the
cursor therefore reappears on the next page.

### My solution
Truncate the column to milliseconds in the comparison so both sides have
the same precision: `date_trunc('milliseconds', ${fieldReference})`.

For the issue of nested sorting filters, when ordering by a composite
field (e.g. `createdBy.name`), `encodeCursor` stored the entire
composite object (`source`, `workspaceMemberId`, `name`, `context`). The
where-condition builder later iterated those sub-keys and threw "Invalid
cursor" because only name had an orderBy direction.

P.S: Duplicate of #20867 because last fork got polluted.

---------

Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
2026-06-01 08:50:14 +00:00
71c377484e fix(front): keep app variable cache in sync after update (#20861)
Updating an application variable in Workspace / Applications / <App> /
Settings persisted server-side but the Apollo cache kept the old value.
Switching tabs unmounted the settings tab, and remounting reseeded the
input from the stale cache — only a full refresh showed the new value.

Mutation now writes the new value into the ApplicationVariable entity
via cache.modify, so FindOneApplication reflects the change immediately.
Hook + table updated to pass the variable id through.

Adds a hook test that pre-seeds the cache and asserts the cached value
after the mutation.

NOTE: saving the plain value in Apollo's cache might not be the best
approach here

---------

Co-authored-by: martmull <martmull@hotmail.fr>
2026-06-01 08:14:28 +00:00
26906951b3 fix(twenty-sdk): minify front-component bundles & set NODE_ENV=production in deploy build (#20937)
## Summary

`twenty deploy` (and `twenty build`) currently ship front-component
`.mjs` bundles **unminified**, with `process.env.NODE_ENV` undefined at
build time. Two missing options in
`get-base-front-component-build-options.ts` — fix is two lines.

## Why this matters

Each front-component bundle includes React + ReactDOM + the design
system AOT (only `twenty-client-sdk/{core,metadata}` are listed in
`FRONT_COMPONENT_EXTERNAL_MODULES`). A trivial widget measures **~2 MB**
unminified. Because every widget mount spawns a fresh Web Worker that
re-fetches and re-parses the bundle (`FrontComponentWorkerEffect.tsx`),
that 2× size translates directly into 2× cold-start latency on **every
record-page navigation and every browser refresh**. On a CRM with even a
handful of custom widgets this dominates perceived UI latency.

## Why it bites every app, not one user

Reference apps in this repo
(`packages/twenty-apps/fixtures/{minimal,rich}-app`,
`community/github-connector`, `internal/twenty-for-twenty`) ship the
same way — verified by inspecting the released `twenty-sdk@2.5.0`
bundle. There's no CLI flag, env var, or config option to opt into a
production build. A search of issues/PRs for "minify", "bundle size",
"production build" surfaces nothing tracking this.

## Fix

Enable `minify: true` and `define: { 'process.env.NODE_ENV':
'"production"' }` in the base front-component build options. These flow
through `build-application.ts` (the orchestrator for `twenty deploy` and
`twenty build`).

**Watch mode (`twenty dev`) is intentionally untouched.**
`esbuild-watcher.ts` has its own configuration path that doesn't consume
`getBaseFrontComponentBuildOptions()`; it stays unminified so local
rebuilds remain fast and stack traces remain readable during
development.

## Measured impact

Two production extension apps using `twenty-sdk@2.5.0`:

| File | Before | After | Δ |
|---|---:|---:|---:|
| `oapps-deal-items` (single widget) | 2,114,953 B | 863,444 B |
**−59%** |
| `oapps-document-hub/documents-panel` | 2,120,341 B | 872,478 B |
**−59%** |
| `oapps-document-hub/hub-document-record` | 2,077,013 B | 852,544 B |
**−59%** |
| `oapps-document-hub/field-mapping-editor` | 1,168,941 B | 255,787 B |
**−78%** |

End-user effect: opening an Opportunity record with two custom-widget
tabs went from ~3-4s widget paint to under 1s on the same machine, same
browser, same record.

## Risk / scope

- **No behavior change.** Minification is a transparent transform;
`NODE_ENV=production` is the standard signal libraries already gate on.
No app code changes needed.
- **No effect on `twenty dev`** — separate code path.
- **No effect on logic functions** — they use their own build-options
object.
- One file touched.

## Test plan

- [ ] `yarn twenty deploy` on
`packages/twenty-apps/fixtures/minimal-app` → output `.mjs` is mangled
and `process.env.NODE_ENV` no longer appears literally inside the
bundle.
- [ ] `yarn twenty dev` on the same app → output `.mjs` remains
readable.
- [ ] Existing CI green.

Happy to add a feature-flag (`TWENTY_BUILD_MODE` env var or `twenty
deploy --no-minify` escape hatch) if maintainers prefer that over
unconditional minification.

---------

Co-authored-by: 8Maverik8 <8maverik8@users.noreply.github.com>
Co-authored-by: martmull <martmull@hotmail.fr>
2026-06-01 08:13:55 +00:00
51202d5a32 fix(front): scroll long content in rich text editor (#20319)
## Summary

Fixes scroll behavior in the rich text editor.

Long content was unbounded — the popup grew off-screen, the
expand-to-side-panel button became unreachable, and the side panel
itself didn't scroll either.

Closes #20309

## Changes

- `RichTextFieldInput`: cap the popup at `min(60vh, 500px)` and wrap the
editor in a scrollable region. Collapse button stays at the top via
`align-items: flex-start`.

- `RecordInlineCellEditMode`: add `shift()` middleware to keep the popup
inside the viewport after `flip()` triggers (previously the popup could
extend above the viewport top with the collapse button out of reach).

- `SidePanelContainer` / `SidePanelRouter`: add `min-height: 0` to the
flex-column chain so the existing `overflow-y: auto` on the content area
can actually clip and scroll long children.

The previous attempt in #20310 added the same `min-height: 0` plus a
nested overflow wrapper inside the rich-text page; the nested wrapper
turned out to be the reason scrolling didn't work.

## Test plan

- [x] Open a record with a long `RICH_TEXT` field
- [x] Click the field — popup opens bounded; long content scrolls inside
- [x] Click the expand button (top-right) — side panel opens
- [x] Side panel: long content scrolls vertically
- [x] Popup near the bottom of the viewport flips upward and stays fully
inside the viewport (collapse button remains visible)
- [x] `lint:diff-with-main`, `typecheck`, prettier — green



https://github.com/user-attachments/assets/827be881-aadd-49ed-9ddc-7566c00cf4be

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 08:13:11 +00:00
Thomas TrompetteandGitHub f4380f89a8 fix: SSE event stream reconnection after idle connection death (#21061)
The SSE event stream could silently die from network partitions, NAT
table flushes, browser tab throttling, or server restarts. When this
happened:
1. The `error` callback only called `captureException` — no reconnection
was triggered
2. The `complete` callback was `() => {}` — a cleanly terminated stream
left the client permanently broken
3. No mechanism existed to detect a silently dead connection where no
FIN/RST was received

## Summary

- **Fix `error`/`complete` callbacks**: The `graphql-sse` subscription's
`error` callback only reported to Sentry, and `complete` was a no-op.
Both now set `shouldDestroyEventStreamState = true` to trigger the
destroy-recreate lifecycle, ensuring detected transport failures and
clean stream terminations lead to automatic reconnection.
- **Add server-side keepalive**: The existing heartbeat timer now runs
every 30s (instead of 6min) and publishes empty events through the Redis
pub/sub channel in addition to refreshing the Redis TTL (throttled to
~6min). Unlike GraphQL Yoga's opaque SSE comment pings, these are real
subscription events that flow through the client's `next`/`message`
handlers.
- **Add client-side keepalive monitor (`SSEKeepAliveEffect`)**: Tracks
the timestamp of the last received event. If no event arrives within 90
seconds (3x the keepalive interval), it clears query listeners and
triggers a stream destroy-recreate cycle.

## Test plan

- [x] Start the app, verify SSE events flow normally (workflow runs
update in real-time)
- [x] Leave the app idle for >90 seconds, then trigger a workflow run —
verify the stream auto-reconnects and events are delivered
- [x] Kill the server, restart it, verify the frontend recovers its
event stream
- [x] Verify keepalive events (empty
`objectRecordEventsWithQueryIds`/`metadataEvents`) appear in browser
network tab every ~30s
- [x] Verify no regressions in SSE-dependent features (record updates,
metadata changes, workflow run visualization)
2026-06-01 08:09:22 +00:00
nitinGitHubclaude[bot] <41898282+claude[bot]@users.noreply.github.com>Félix Malfait
e430e4ea0a fix(ai): route xAI search through Responses API as native tools (#21037)
xAI deprecated Live Search, so the `searchParameters` provider option
now returns 410. This routes all xAI models through the Responses API
and binds web/X search as native agent tools, matching how
Anthropic/OpenAI expose search.

- xAI provider now uses `provider.responses()` — its
`webSearch()`/`xSearch()` tools only run against the Responses endpoint,
not chat completions
- web/X search migrated from the `provider-option` variant to `sdk-tool`
(`web_search`/`x_search`); deleted the dead `searchParameters` path, the
`provider-option` variant, and `providerOptions` on `NativeModelBinding`
- dropped a dead `rolePermissionConfig` param on `getAgentRoleId`, left
over from #20331

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Félix Malfait <FelixMalfait@users.noreply.github.com>
2026-05-31 15:11:04 +02:00
Abdullah.andGitHub b027e4bdb1 [Website] i18n module, page-local sections, translatable copy (#21082)
**i18n** — collapsed the ~22 scattered i18n files into a single module
and turned on Spanish alongside French.

**Sections** — dropped the old compound pattern (`Section.Root`,
`Section.Heading`, …). Reusable layout shells moved to `src/templates/`,
atomic bits stay in `design-system/`, and each page now owns its copy in
local `_components` blocks instead of pulling it out of shared sections.
Data files hold arrays only, no prose.

**Copy → `<Trans>`** — A lot of headings were split across several
`<HeadingPart>`s just for font styling, which meant each piece was a
separate translation string. A translator got "Build your Enterprise
CRM" and "at AI Speed" as two unrelated strings and had no way to
reorder them for their language. Those are now single `<Trans>` units
with placeholders. Same idea for the old `\n` + `white-space: pre-line`
line-break trick: replaced with a small `ResponsiveLineBreak` element so
the break is doesn't quietly rot, and did a dead-code pass.

The de-fragmentation changes the message IDs, so around 60 strings will
fall back to English in fr/es until Crowdin re-syncs.
2026-05-31 12:39:35 +00:00
fc90b4ba8b i18n - docs translations (#21064)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-05-29 19:30:26 +02:00
88b77cb699 feat(server): opt-in FRONT_AUTO_BASE_URL for hostname-relative API URL (#20504)
## Problem

`generateFrontConfig()` writes `window._env_.REACT_APP_SERVER_BASE_URL =
process.env.SERVER_URL` unconditionally. The frontend then pins to that
absolute URL. For self-hosted deployments reachable from multiple
hostnames (Tailscale IP, LAN IP, internal DNS, SSH tunnel to localhost,
public DNS), only the one matching `SERVER_URL` works — others hit CORS
errors or unreachable hosts because the frontend tries to call the API
at the configured URL, not the one the user came in via.

The frontend already supports the right fallback:
`packages/twenty-front/src/config/index.ts:20-21` reads
`window._env_?.REACT_APP_SERVER_BASE_URL` and falls back to
`getDefaultUrl()` (which uses `window.location`) when the env var is
absent. But the server-side `generateFrontConfig` always populates
`_env_`, so the fallback never runs.

## Fix

One file: `packages/twenty-server/src/utils/generate-front-config.ts`.
Add a `FRONT_AUTO_BASE_URL=true` opt-in (also triggered when
`SERVER_URL` is unset entirely). When the toggle is on, inject
`window._env_ = {}` so the frontend's existing `getDefaultUrl()`
fallback resolves the origin from `window.location` at runtime.

## Backwards compatibility

When `SERVER_URL` is set AND `FRONT_AUTO_BASE_URL` is unset (or anything
other than `'true'`): unchanged — `REACT_APP_SERVER_BASE_URL:
process.env.SERVER_URL` is injected exactly as before.

The toggle is strictly additive. Existing single-hostname deployments
are not affected.

## Use case

Self-hosted Twenty reachable via:
- `http://100.115.12.29` over Tailscale
- `http://localhost:4440` over SSH tunnel
- `http://twenty.internal` over LAN DNS
- `http://crm.example.com` public

With `FRONT_AUTO_BASE_URL=true`, all four paths work without rebuilds or
per-hostname server processes.

## Test plan

- [ ] `SERVER_URL=http://x.com` (toggle unset) → `<script>window._env_ =
{"REACT_APP_SERVER_BASE_URL":"http://x.com"};</script>` (unchanged from
main)
- [ ] `SERVER_URL` unset → `<script>window._env_ = {};</script>` (new
fallback path)
- [ ] `SERVER_URL=http://x.com FRONT_AUTO_BASE_URL=true` →
`<script>window._env_ = {};</script>` (toggle wins)
- [ ] `FRONT_AUTO_BASE_URL=false SERVER_URL=http://x.com` → unchanged
(only `'true'` triggers the toggle)

---------

Co-authored-by: martmull <martmull@hotmail.fr>
2026-05-29 16:26:43 +00:00
Raphaël BosiandGitHub 0ed2e9d82d Docs: clarify numberOfSelectedRecords usage for RECORD_SELECTION items (#21059)
Add a note to the command menu items docs explaining that
RECORD_SELECTION already guarantees a non-empty selection, so
numberOfSelectedRecords > 0 is redundant in
conditionalAvailabilityExpression.
2026-05-29 17:59:14 +02:00
643cfe9b13 i18n - docs translations (#21062)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-05-29 17:29:40 +02:00
Thomas TrompetteandGitHub bc1b7f6fdf fix: resolve workflow form step auto-open race condition (#21053)
## Summary
- Fix intermittent failure where the Quick Lead workflow form step did
not auto-open
- Root cause: race conditions between SSE events, Apollo cache writes,
and the `runWorkflowVersion` mutation timing
- Add generic monotonicity guard in the SSE handler that drops stale
updates for all records (not just WorkflowRun)

## Changes
- **`useTriggerOptimisticEffectFromSseUpdateEvents.ts`**: Compare
incoming `updatedAt` with cached record before writing — skip if stale.
Moved `upsertRecordsInStore` after the guard so neither Apollo cache nor
Jotai store receive stale data.
- **`useRunWorkflowVersion.tsx`**: Await mutation before opening side
panel; register SSE listener eagerly before mutation
- **`useWorkflowRun.ts`**: Simplified back to plain `useFindOneRecord` +
schema parse (no extra state needed)
- **`generateWorkflowRunDiagram.ts`**: `shouldOpenStep` matches both
PENDING and RUNNING for form steps (backend RUNNING means "waiting for
user input")
- **`WorkflowRunVisualizerEffect.tsx`**: Pass `runStatus` directly
without status mapping
- **`WorkflowRunStepNodeDetail.tsx`**: Form is interactive when step is
PENDING or RUNNING
- **Deleted `latestWorkflowRunFamilyState.ts`**: No longer needed — the
generic SSE guard replaces it

## Test plan
- [x] Hard refresh, run Quick Lead workflow 10+ times — form should
always auto-open
- [x] Complete the form and verify all subsequent steps execute without
getting stuck
- [x] Verify the workflow diagram is always visible (never disappears)
- [x] Verify other record types still update correctly via SSE (e.g.
edit a person in another tab)
2026-05-29 14:18:24 +00:00
Raphaël BosiandGitHub 57118a868f Docs update: Calling a logic function from a front component (#21057)
Documents how a headless front component calls a server-side logic
function over HTTP via the /s/ route, so AI agents have a clear
reference for implementing this pattern.
2026-05-29 14:04:19 +00:00
93f848fd2f i18n - translations (#21055)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-05-29 15:30:31 +02:00
Félix MalfaitandGitHub 667cb95730 fix(sso): accept HTTP-POST binding and surface descriptive parser errors (#21051)
Fixes https://github.com/twentyhq/twenty/issues/21044

## Summary
- Closes [#490](https://github.com/twentyhq/private-issues/issues/490) —
JumpCloud customers (and anyone else whose IdP only advertises
`HTTP-POST` for `SingleSignOnService`) could not upload their SAML
metadata; the parser silently rejected them with a generic `Invalid
file` toast.
- The SAML IdP metadata parser now falls back to `HTTP-POST` when
`HTTP-Redirect` is not advertised. Both are valid SAML 2.0 bindings.
- The parser now returns a descriptive `reason` string (Zod issues +
custom errors) instead of an opaque `error: unknown`, and the upload
snack bar surfaces it so the customer can self-diagnose (e.g. `entityID:
entityID is not a valid URL` if they forgot to fill in their IdP Entity
ID).
- Added unit tests for HTTP-POST-only metadata, HTTP-Redirect
preference, and each descriptive-error path.

## Test plan
- [x] `npx jest parseSAMLMetadataFromXMLFile
--config=packages/twenty-front/jest.config.mjs` — 8/8 pass
- [x] `npx oxlint -c packages/twenty-front/.oxlintrc.json` on changed
files — clean
- [x] `npx oxfmt --check` on changed files — clean
- [ ] Manual: upload the customer's JumpCloud metadata (HTTP-POST only,
placeholder `entityID`) and confirm the error now says `Invalid file:
entityID: entityID is not a valid URL` instead of `Invalid file`
- [ ] Manual: upload metadata with a real `entityID` and HTTP-POST-only
binding, confirm the form populates correctly
2026-05-29 15:22:50 +02:00
martmullandGitHub 4e5d47168c 2439 improve command menu item display in right panel (#21020)
## Before

<img width="1512" height="389" alt="image"
src="https://github.com/user-attachments/assets/33274356-fb99-4a02-baa7-c324e6d151c6"
/>


## After

<img width="1512" height="357" alt="image"
src="https://github.com/user-attachments/assets/c0affb71-e920-4d64-b2f0-1bed53209ea5"
/>
2026-05-29 11:37:27 +00:00
neo773andGitHub 10c0bed462 fix: harden email-group SES provisioning, cleanup, and inbound replay (#21046)
## Changes

**Provisioning idempotent** (`aws-ses-register-domain.service.ts`)
- Each SES create call (`CreateConfigurationSet`, event destination,
contact list, tenant association) now swallow `AlreadyExistsException`
via `.send().catch()`.
- Retry after partial failure re-run every step, no blow up on "already
exists". Before: one existing resource kill whole provision.

**Workspace delete clean up cloud** (`workspace.service.ts`,
`emailing-domain-workspace-cleanup.job.ts`,
`emailing-domain.service.ts`)
- On workspace delete, fetch domain list first, pass domains to cleanup
job.
- Cleanup now loop `driver.cleanupDomain(domain)` per domain +
`deprovisionWorkspace`. Tear down SES identity/tenant/config-set, not
just delete DB rows.
- Before: DB rows gone, SES resources orphaned forever. Now: cloud match
DB.

**Inbound replay dedupe** (`ses-inbound-mail-handler.service.ts`)
- Use `snsMessageId` as job id. SNS deliver same message twice → second
is no-op. No duplicate inbound email import.
2026-05-29 10:31:03 +00:00
08b1c5738d i18n - translations (#21050)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-05-29 12:12:35 +02:00
martmullandGitHub c2df39405c Fix admin pannel server variable config tab (#21017)
## Before

<img width="1046" height="490" alt="image"
src="https://github.com/user-attachments/assets/450557de-fcf5-4b51-afdb-36c0c36e43d8"
/>


## After

<img width="1040" height="414" alt="image"
src="https://github.com/user-attachments/assets/4a5fe2ab-85d6-4431-9397-6f81ae24055d"
/>
2026-05-29 09:55:11 +00:00
3e2c50c6cf i18n - translations (#21048)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-05-29 11:21:23 +02:00
f67db8d8b2 i18n - translations (#21047)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-05-29 11:17:19 +02:00
3cd2458fdf i18n - website translations (#21045)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-05-29 11:12:31 +02:00
nitinandGitHub 13f09d8946 [Dashboards] Remove gauge chart types and code (#20410)
Follow-up cleanup to #20172.
2026-05-29 11:08:50 +02:00
martmullandGitHub 961745e9ba Fix latency spike on application lookup (#21042)
Fixes
https://discord.com/channels/1130383047699738754/1509645089062781058

Caching application entities to improve authentication latency
2026-05-29 11:06:36 +02:00
MarieandGitHub 41832c8d82 Fix workflow creation on view filtered by status (#21027)
Creating a workflow on a table with with a filter on status (eg: status
is "active") failed because it added the status to createOneWorkflow (in
order to have the record belonging to the view) - while
createOneWorkflow throwed a 400 exception when attempting to create a
workflow with a status (does not correpsond to a valid behaviour).

Silently stripping status rom create workflow endpoints.
2026-05-29 08:36:59 +00:00
3041ed3b6e chore: sync AI model catalog from models.dev (#21041)
Automated daily sync of `ai-providers.json` from
[models.dev](https://models.dev).

This PR updates pricing, context windows, and model availability based
on the latest data.
New models meeting inclusion criteria (tool calling, pricing data,
context limits) are added automatically.
Deprecated models are detected based on cost-efficiency within the same
model family.

**Please review before merging** — verify no critical models were
incorrectly deprecated.

Co-authored-by: FelixMalfait <6399865+FelixMalfait@users.noreply.github.com>
2026-05-29 09:12:25 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Félix MalfaitWeikoclaude[bot] <41898282+claude[bot]@users.noreply.github.com>Félix Malfait
6d550611d2 chore(deps): bump typescript from 5.9.2 to 5.9.3 (#20991)
Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.9.2
to 5.9.3.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/microsoft/TypeScript/releases">typescript's
releases</a>.</em></p>
<blockquote>
<h2>TypeScript 5.9.3</h2>
<p>Note: this tag was recreated to point at the correct commit. The npm
package contained the correct content.</p>
<p>For release notes, check out the <a
href="https://devblogs.microsoft.com/typescript/announcing-typescript-5-9/">release
announcement</a></p>
<ul>
<li><a
href="https://github.com/Microsoft/TypeScript/issues?utf8=%E2%9C%93&amp;q=milestone%3A%22TypeScript+5.9.0%22+is%3Aclosed+">fixed
issues query for Typescript 5.9.0 (Beta)</a>.</li>
<li><a
href="https://github.com/Microsoft/TypeScript/issues?utf8=%E2%9C%93&amp;q=milestone%3A%22TypeScript+5.9.1%22+is%3Aclosed+">fixed
issues query for Typescript 5.9.1 (RC)</a>.</li>
<li><em>No specific changes for TypeScript 5.9.2 (Stable)</em></li>
<li><a
href="https://github.com/Microsoft/TypeScript/issues?utf8=%E2%9C%93&amp;q=milestone%3A%22TypeScript+5.9.3%22+is%3Aclosed+">fixed
issues query for Typescript 5.9.3 (Stable)</a>.</li>
</ul>
<p>Downloads are available on:</p>
<ul>
<li><a href="https://www.npmjs.com/package/typescript">npm</a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/microsoft/TypeScript/commit/c63de15a992d37f0d6cec03ac7631872838602cb"><code>c63de15</code></a>
Bump version to 5.9.3 and LKG</li>
<li><a
href="https://github.com/microsoft/TypeScript/commit/8428ca4cc8a7ecc9ac18dd0258016228814f5eaf"><code>8428ca4</code></a>
🤖 Pick PR <a
href="https://redirect.github.com/microsoft/TypeScript/issues/62438">#62438</a>
(Fix incorrectly ignored dts file fr...) into release-5.9 (#...</li>
<li><a
href="https://github.com/microsoft/TypeScript/commit/a131cac6831aa6532ea963d0cb3131b957cad980"><code>a131cac</code></a>
🤖 Pick PR <a
href="https://redirect.github.com/microsoft/TypeScript/issues/62351">#62351</a>
(Add missing Float16Array constructo...) into release-5.9 (#...</li>
<li><a
href="https://github.com/microsoft/TypeScript/commit/04243333584a5bfaeb3434c0982c6280fe87b8d5"><code>0424333</code></a>
🤖 Pick PR <a
href="https://redirect.github.com/microsoft/TypeScript/issues/62423">#62423</a>
(Revert PR 61928) into release-5.9 (<a
href="https://redirect.github.com/microsoft/TypeScript/issues/62425">#62425</a>)</li>
<li><a
href="https://github.com/microsoft/TypeScript/commit/bdb641a4347af822916fb8cdb9894c9c2d2421dd"><code>bdb641a</code></a>
🤖 Pick PR <a
href="https://redirect.github.com/microsoft/TypeScript/issues/62311">#62311</a>
(Fix parenthesizer rules for manuall...) into release-5.9 (#...</li>
<li><a
href="https://github.com/microsoft/TypeScript/commit/0d9b9b92e2aca2f75c979a801abbc21bff473748"><code>0d9b9b9</code></a>
🤖 Pick PR <a
href="https://redirect.github.com/microsoft/TypeScript/issues/61978">#61978</a>
(Restructure CI to prepare for requi...) into release-5.9 (#...</li>
<li><a
href="https://github.com/microsoft/TypeScript/commit/2dce0c58af51cf9a9068365dc2f756c61b82b597"><code>2dce0c5</code></a>
Intentionally regress one buggy declaration output to an older version
(<a
href="https://redirect.github.com/microsoft/TypeScript/issues/62163">#62163</a>)</li>
<li>See full diff in <a
href="https://github.com/microsoft/TypeScript/compare/v5.9.2...v5.9.3">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=typescript&package-manager=npm_and_yarn&previous-version=5.9.2&new-version=5.9.3)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Weiko <corentin@twenty.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Félix Malfait <FelixMalfait@users.noreply.github.com>
2026-05-29 08:39:35 +02:00
Félix MalfaitandGitHub 3c97d9648b fix(address): show saved address in record detail when street1 is null (#21033)
## Fixes #20084

### Problem
A saved address is visible in the **table view** but shows **"Empty"**
in the **record detail page** when `addressStreet1` is `null`.

This reproduces with the default seed data out of the box — e.g.
**Google** (city "Mountain View", no street), **Microsoft** (Redmond),
**Meta** (Menlo Park) — which is why several users reported hitting it
immediately.

### Root cause
The frontend zod schema required `addressStreet1` to be a **non-null**
string:

```ts
// isFieldAddressValue.ts
export const addressSchema = z.object({
  addressStreet1: z.string(),          // ← required non-null
  addressStreet2: z.string().nullable(),
  ...
});
```

…but the backend composite type marks it `isRequired: false`
(`address.composite-type.ts`), and the DB column is nullable. So the API
legitimately returns `addressStreet1: null` when only other subfields
are filled.

The two views diverge on how they render:

- **Record detail** gates the value behind `useIsFieldEmpty()` →
`isFieldValueEmpty()`, which for addresses calls
`isFieldAddressValue()`. With `addressStreet1: null` the `safeParse`
**fails**, so `isFieldValueEmpty` returns `true` and the `"Empty"`
placeholder is shown (`RecordInlineCellDisplayMode`).
- **Table view** (`RecordTableCellDisplayMode`) renders
`AddressFieldDisplay` directly with **no** empty check, so the address
stays visible.

This was a latent mismatch since the address guard was introduced.

### Fix
Make `addressStreet1` nullable to match the backend and the other
subfields:

- `addressSchema` → `addressStreet1: z.string().nullable()`
- `FieldAddressValue.addressStreet1` → `string | null`
- `FieldAddressDraftValue.addressStreet1` → `string | null` (keeps the
input/draft type consistent; the text input already renders `?? ''`)

The change is strictly more permissive — persisting and the settings
default-value form still accept string values; they now also accept
`null`.

### Tests
- `isFieldAddressValue.test.ts` — guard returns `true` for
`addressStreet1: null` with other subfields filled.
- `isFieldValueEmpty.test.ts` — new address coverage: empty address is
empty; **`street1: null` + city filled is NOT empty**; normal address is
not empty. (Added an `addressFieldDefinition` mock.)

Both new assertions were confirmed to **fail before the fix** and pass
after.

### Verification
- `npx jest isFieldValueEmpty isFieldAddressValue
normalize-address-field-value-for-persist` → 17 passed
- `npx nx typecheck twenty-front` → pass
- `npx nx lint:diff-with-main twenty-front` → 0 warnings, 0 errors
2026-05-29 08:38:47 +02:00
Félix MalfaitandGitHub 3afdabb93e fix(dashboards): isolate pie chart slice labels per widget (#21034)
## Summary

Fixes [#21014](https://github.com/twentyhq/twenty/issues/21014).

When two pie chart widgets shared the same group-by field (and therefore
the same slice ids) but used different aggregation operators (e.g.
`count` vs `sum`), the arc-link labels would mirror between the two
charts — both ending up showing either the count or the sum values,
depending on render order. Center metrics stayed correct.

**Root cause.** Nivo's `ArcLinkLabelsLayer` and `ArcsLayer` (from
`@nivo/arcs`) wire `react-spring`'s `useTransition` with `keys: e =>
e.id`. When two `<ResponsivePie>` instances render with overlapping ids,
the transitioned data bleeds across charts. The center metric is
unaffected because it's computed by a separate hook
(`usePieChartCenterMetricData`).

**Fix.** Namespace the Nivo-computed slice id per widget by passing an
`id` accessor to `<ResponsivePie>`:
```tsx
id={(datum) => `${id}:${String(datum.id)}`}
```
Lookups inside the widget switch to `datum.data.id` (the original,
un-namespaced id stored on the raw datum), so value/percentage
formatting, the custom tooltip, and the legend hover-dim behavior all
keep working.

Touched files:
- `GraphWidgetPieChart.tsx` — add `id` accessor
- `CustomArcsLayer.tsx` — compare legend highlight against
`datum.data.id`
- `getPieChartFormattedValue.ts`, `getPieChartTooltipData.ts` — match on
`datum.data.id`
- Tests for both utils get a regression case covering the namespaced
computed id

## Test plan

- [ ] `npx jest getPieChartFormattedValue` 
- [ ] `npx jest getPieChartTooltipData` 
- [ ] `npx tsc --noEmit` 
- [ ] Manual: dashboard with two pies on the same group-by field, one
`count` and one `sum`, "Display data label" on for both — confirm each
chart shows its own metric on the slices, and the central total is
unchanged.
- [ ] Manual: hover a legend item — the matching slice in that chart
stays solid while the others dim, and the sibling chart is not affected.
- [ ] Manual: clicking a slice still drills into the correctly filtered
view.
2026-05-29 05:51:10 +00:00
25b0e0d091 fix: correct typo occurence -> occurrence in metadata-event-emitter.ts (#21036)
## Summary

Fixes a spelling typo in
`packages/twenty-server/src/engine/subscriptions/metadata-event/metadata-event-emitter.ts`:

- Variable name `occurence` → `occurrence` (4 references on lines 101,
103, 114, 115)

## Changes

-
`packages/twenty-server/src/engine/subscriptions/metadata-event/metadata-event-emitter.ts`
— rename misspelled variable

Co-authored-by: james <li@jamesdeMacBook-Pro.local>
2026-05-29 05:48:57 +00:00
a43e5c3fb3 i18n - translations (#21032)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-05-28 22:15:29 +02:00
996cdaf3ff refactor(agents): split tool resolution into native and action rails (#20331)
## Summary

Splits AI agent tool resolution into two independent rails:

- **Native tools** — capabilities baked into the model SDK
(Anthropic/OpenAI `web_search`, xAI `web`/`x` provider options). Bound
by `NativeToolBinderService`, controlled by per-agent
`modelConfiguration` toggles. Opaque to Twenty — executed on the model
provider's servers.
- **Action tools** — registry-scoped tools from `ToolRegistryService`
(code interpreter, send email, record CRUD, etc.). Permission-gated via
the agent's role. Executed on Twenty's server.

Both rails merge into a single `ToolSet` at call time. When both
surfaces expose a search tool the model picks at runtime — coexistence
is intentional (relevant once Exa returns as an action, see below).

## Notable changes worth calling out

**Contract change: `AgentAsyncExecutorService.executeAgent` no longer
accepts `rolePermissionConfig`.** Workflow agents now scope exclusively
by the agent's own permission-tab role (`unionOf: [agentRoleId]`). The
previous role-merging path (caller role intersected with agent role) is
removed. No agent role → no registry tools (fail-closed by design).

**`NativeToolBinderService` relocated** from
`core-modules/tool-provider/native/` →
`metadata-modules/ai/ai-models/services/`. The binder needs SDK-package
knowledge, which lives in `ai-models`. Old location created a backwards
module dependency.

**`NATIVE_MODEL_TOOLS_BY_SDK_PACKAGE` is exhaustive over
`AiSdkPackage`** (`Record<>`, not `Partial<Record<>>`). Adding a new SDK
without thinking about native tools now fails the build. SDKs without
native tools (Bedrock, Google, Mistral, Azure, OpenAI-compatible) get
explicit `{}` entries.

**Discriminated union `kind: 'sdk-tool' | 'provider-option'`** lets one
registry describe both function tools (Anthropic/OpenAI) and runtime
sources (xAI). Follows the local `tool-provider` convention from #19321.

## Deferred to follow-ups

- **Exa web search is dropped from this PR** (along with its
`WEB_SEARCH_TOOL` permission flag and the Exa-specific gating). Exa
comes back as an **action/app tool** once apps can define permission
flags through the SDK — ongoing work in #20481.
- **xAI native search currently errors.** xAI deprecated its Live Search
API (the `web`/`x` provider-option sources this rail maps to), so xAI
returns `410` when native search is actually exercised. The code path
itself is clear — it's only hit if you test xAI native tools. Fixed
separately alongside the broader xAI model fixes.

## Conscious non-decisions

- **No "twenty-native" category.** `native` is reserved for
model/provider SDK features; everything Twenty-owned is just a
tool/action.
- **Coexistence over precedence.** No rule forcing an action search tool
to override native search (or vice-versa) — when both exist, it's the
user's choice in workflow agents and the model's choice in chat.

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
2026-05-28 22:08:05 +02:00
Raphaël BosiandGitHub 1d84695fb0 Fix: focus stack overwritten when auto-opening title cell on new record (#21029)
Fixes https://github.com/twentyhq/twenty/issues/20894

In `PageChangeEffect` `resetFocusStackToFocusItem` ran right after
`openNewRecordTitleCell`, wiping the title cell entry. Typing in the
auto-opened breadcrumb input (e.g. new workflow) triggered global
shortcuts and ignored Enter / Escape / Tab.

Reordered so the page reset runs first, then the title cell push lands
on top.

## Before


https://github.com/user-attachments/assets/d3c0c266-a493-46b8-b99b-32f4381b8664


## After


https://github.com/user-attachments/assets/3a886386-f438-46d2-a6ea-9ee6908d6df2
2026-05-28 19:23:40 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Weiko
64e0b76d00 chore(deps): bump js-cookie from 3.0.5 to 3.0.7 (#20992)
Bumps [js-cookie](https://github.com/js-cookie/js-cookie) from 3.0.5 to
3.0.7.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/js-cookie/js-cookie/releases">js-cookie's
releases</a>.</em></p>
<blockquote>
<h2>v3.0.7</h2>
<ul>
<li>Prevent cookie attribute injection: CVE-2026-46625 (eb3c40e)</li>
<li>Add <code>Partitioned</code> attribute to readme (b994768)</li>
<li>Publish to npm registry via trusted publisher exclusively
(4dc71be)</li>
<li>Ensure consistent behaviour for <code>get('name')</code> +
<code>get()</code> (1953d30)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/js-cookie/js-cookie/commit/17bacba0171dd022728d8fdeba3203c60791bf58"><code>17bacba</code></a>
Craft v3.0.7 release</li>
<li><a
href="https://github.com/js-cookie/js-cookie/commit/adb823cb7e95ead47f3af4d4951e589acbde2077"><code>adb823c</code></a>
Fix release workflow halting at <code>git tag</code></li>
<li><a
href="https://github.com/js-cookie/js-cookie/commit/5f9e759b07d2752e8407a3a43fb5f879bf384c5e"><code>5f9e759</code></a>
May remove Git user config from release workflow</li>
<li><a
href="https://github.com/js-cookie/js-cookie/commit/6ac921184c7b3b7d9431c88707f56521acd72ab4"><code>6ac9211</code></a>
Fix release workflow not able to push commit + tag</li>
<li><a
href="https://github.com/js-cookie/js-cookie/commit/2278bc55e1804c4c2d9bd2110a9b449949a52751"><code>2278bc5</code></a>
Fix missing package version bump</li>
<li><a
href="https://github.com/js-cookie/js-cookie/commit/eb3c40e89731e99b8970faaf35ddad249c6c0020"><code>eb3c40e</code></a>
Prevent cookie attribute injection</li>
<li><a
href="https://github.com/js-cookie/js-cookie/commit/f6f157f430d707d2ffd0c9c9138227a6cea564e5"><code>f6f157f</code></a>
Bump globals from 17.5.0 to 17.6.0</li>
<li><a
href="https://github.com/js-cookie/js-cookie/commit/f409d022da50a0c6fa8724f087fbc50fab9a9533"><code>f409d02</code></a>
Bump eslint from 10.2.0 to 10.3.0</li>
<li><a
href="https://github.com/js-cookie/js-cookie/commit/a686883c03a754c04546cfc1653911a70a640b40"><code>a686883</code></a>
Bump protobufjs in the npm_and_yarn group across 1 directory</li>
<li><a
href="https://github.com/js-cookie/js-cookie/commit/c6112d2d4f2881a12aaf89d9e2996ef6870eb6d0"><code>c6112d2</code></a>
Bump <code>@​protobufjs/utf8</code> in the npm_and_yarn group across 1
directory</li>
<li>Additional commits viewable in <a
href="https://github.com/js-cookie/js-cookie/compare/v3.0.5...v3.0.7">compare
view</a></li>
</ul>
</details>
<details>
<summary>Maintainer changes</summary>
<p>This version was pushed to npm by <a
href="https://www.npmjs.com/~GitHub%20Actions">GitHub Actions</a>, a new
releaser for js-cookie since your current version.</p>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=js-cookie&package-manager=npm_and_yarn&previous-version=3.0.5&new-version=3.0.7)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Weiko <corentin@twenty.com>
2026-05-28 21:25:35 +02:00
Félix MalfaitandGitHub 8a74ea8829 fix(contact-creation): enrich missing names on auto-created contacts (#21018)
## Summary

Three related fixes to the auto-creation of People records from calendar
events and email messages, all centred on the data-quality problem of
contacts being created with missing or malformed names.

### 1. Enrich names on existing contacts (commit 1)

Previously: when an email or calendar import matched an existing Person
by email, the existing record was left untouched — even if the new
source carried a better name.

This is the root cause of contacts like `"Félix"` (no last name)
sticking around forever: the `To:`/`Cc:` headers of outbound emails
rarely include a display name, and Google Calendar only returns
`displayName` for attendees already in the organizer's address book. So
the first sighting often creates a Person as `{firstName: "felix",
lastName: ""}`, and a later inbound `From: "Félix Malfait"
<felix@twenty.com>` — which would have produced the right name — gets
silently dropped because the Person already exists.

The new `computePeopleToEnrichNames` bucket and
`CreatePersonService.enrichPeopleNames` method fill in missing
`firstName`/`lastName` fields from the new parsed name, with
conservative rules:
- Only enrich when the existing Person's `createdBy.source` is
`CALENDAR` or `EMAIL` — `MANUAL`, `IMPORT`, `API`, `WORKFLOW`, etc. are
never touched.
- Only fill empty fields. Non-empty `firstName`/`lastName` are never
overwritten.
- Soft-deleted contacts continue to be handled by the existing restore
path.

### 2. Handle multi-comma "Last, First, Suffix" display names (commit 2)

The comma-inverted swap in the parser previously required *exactly* one
comma. Names like `"Smith, Jane, Jr."`, `"O'Brien, Mary, MD"` or `"Doe,
John, Patrick"` fell through to the space-split fallback, which stored
the comma in `firstName` (e.g. `"Smith,"`) and produced garbled records
(the avatar shows a single "B" and the name reads `"Barbey, Julien"`
because the entire string lives in `firstName`).

The regex now splits on the first comma and treats the remainder as the
first name, collapsing any further commas to spaces. Single-comma
behaviour is unchanged.

### 3. Perf: skip the parser when an existing record is already
populated (commit 3)

`computePeopleToEnrichNames` runs on every cron-driven email/calendar
import batch. The first version called the display-name parser for every
matched existing person, even when both `firstName` and `lastName` were
already set — i.e. the steady-state case after the initial enrichment
pass.

Reordered so the cheap "both fields populated" check short-circuits
before any parsing happens. Same behaviour, fewer parser calls on the
hot path.

## Test plan

- [x] 8 new unit tests for the enrichment bucket: empty `lastName`
enrichment, both `EMAIL` and `CALENDAR` sources, non-overwrite of
non-empty fields, skip on `MANUAL`/`IMPORT`, skip when the new source
also has no last name, skip for soft-deleted, fill `firstName` while
preserving `lastName`, handle null `name` field
- [x] 5 new parser tests for multi-comma forms: `"Last, First, Suffix"`,
credential suffixes (`MD`), three-token forms, whitespace around inner
commas, `:GROUP` tag interaction
- [x] 1 new parser test on the single-comma path covering multi-word
first names (`"Smith, Mary Jane"`)
- [x] All 15 existing parser tests still pass
- [x] All 116 tests in `contact-creation-manager` pass
- [x] `npx nx typecheck twenty-server`
- [x] `npx oxlint --type-aware` + `npx oxfmt --check` on changed files
- [ ] Manual: trigger a fresh contact creation from an outbound email
with no display name, then a subsequent inbound email from the same
address with a full display name, and confirm the Person's last name
gets populated
2026-05-28 20:49:56 +02:00
Félix MalfaitandGitHub f4ead89956 refactor(twenty-orm): migrate 23 grandfathered entities to WorkspaceScopedRepository (#20987)
## Summary

Follow-up to #20953. Migrates 23 of the 30 entities that were left in
`WORKSPACE_SCOPED_EXEMPTIONS` last time, so the lint rule's
workspaceId-enforcement default now covers most of the core/metadata
schema.

### Migrated (23 entities, 88 files, 22 commits)

| Family | Entities |
|---|---|
| Trivial caches | `NavigationMenuItem`, `Skill`, `DataSource`,
`Webhook`, `CommandMenuItem`, `IndexMetadata` |
| Views | `View`, `ViewField`, `ViewFieldGroup`, `ViewFilter`,
`ViewFilterGroup`, `ViewGroup`, `ViewSort` |
| Layouts | `PageLayout`, `PageLayoutTab`, `PageLayoutWidget` |
| Roles & permissions | `Role`, `RoleTarget`, `PermissionFlag`,
`ObjectPermission`, `FieldPermission`, `RowLevelPermissionPredicate`,
`RowLevelPermissionPredicateGroup` |

For each entity: swap `@InjectRepository(X)` →
`@InjectWorkspaceScopedRepository(X)` (and the field type →
`WorkspaceScopedRepository<X>`); rewrite every call site to pass
`workspaceId` as the first arg (stripped from `where`/criteria — the
wrapper throws if you include it now); register
`provideWorkspaceScopedRepository(X)` in every owning NestJS module;
update affected spec providers to
`getWorkspaceScopedRepositoryToken(X)`.

### Rule update

- `ApplicationRegistrationVariableEntity` was misclassified — moved to
`STRUCTURAL_EXEMPTIONS` (no `workspaceId` column; it's keyed on
`applicationRegistrationId` at the instance level).
- 22 of the 23 migrated entities removed from
`WORKSPACE_SCOPED_EXEMPTIONS` entirely (zero remaining raw
`@InjectRepository` sites).
- `RoleTargetEntity` also removed; one call site in
`user-workspace.service.ts` keeps a raw injection with an
`eslint-disable` + reason because `softRemove(...)` is not on the
wrapper API yet (the migration would require threading `workspaceId`
through `deleteUserWorkspace`'s three callers).

### Still exempted (7 entities, follow-up PRs)

| Entity | Why deferred |
|---|---|
| `ApplicationEntity` | ~50 sites with several cross-workspace lookups
by id (auth, OAuth, file-storage, cleanup) |
| `CalendarChannelEntity` / `MessageChannelEntity` | Use
`.increment(...)` (not on wrapper) and
`repository.manager.transaction(...)` — wrapper needs to grow
`.increment` + the transaction sites need `withManager` or dual-inject |
| `FieldMetadataEntity` / `ObjectMetadataEntity` | The metadata services
`extends TypeOrmQueryService<X>` and `super(rawRepo)` — requires
dual-inject or reworking the inheritance |
| `KeyValuePairEntity` | Allows `workspaceId: IsNull()` for
instance-level config; wrapper rejects null |
| `UpgradeMigrationEntity` | Same — instance-level + cross-workspace
ledger |

## Test plan

- [x] `npx nx typecheck twenty-server` — clean
- [x] `npx nx lint twenty-server` — clean (0/0)
- [x] All 10 affected unit specs pass (115 tests) — api-key, agent-role,
permissions, workspace-roles-permissions-cache, view-filter-group,
workflow-version-step-operations, two-factor-authentication (service +
resolver), user-workspace, file
- [ ] Server integration tests in CI
2026-05-28 20:46:21 +02:00
Félix MalfaitandGitHub 865ca697ca Fix AI permission gating: use Ask AI for chat UI, AI Settings for admin endpoints (#21030)
## Summary

Closes #20662.

Two AI permission flags exist:
- **`AI`** (label "Ask AI") — user-facing: chat with AI agents, use AI
features
- **`AI_SETTINGS`** (label "AI") — admin: create and configure AI agents

After auditing every use of these flags I found:

### Frontend — chat UI gated by the admin permission (user-facing bug
from the issue)
A user granted only `Ask AI` could not see chat tabs, the "new chat"
button (desktop & mobile), or the chat content pane; thread
initialization was also skipped, leaving the chat in a half-initialized
state and producing intermittent `THREAD_NOT_FOUND` errors. Switched
these to `AI`:
- `MainNavigationDrawerTabsRow.tsx`
- `MainNavigationDrawer.tsx`
- `MobileNavigationBar.tsx`
- `AgentChatThreadInitializationEffect.tsx`

### Backend — admin-only resolvers gated by the user permission
(privilege escalation)
Two resolvers had a class-level guard of `AI`, letting any user with the
user-facing flag reach admin endpoints (skill CRUD, eval runs). Switched
the class-level guards to `AI_SETTINGS`:
- `SkillResolver` — create/update/delete/activate/deactivate skills
- `AgentTurnResolver` — read turns, run/grade evaluations

### Left as-is (already correct)
- `AgentResolver` — class-level `AI` for reads (workflow editors and
admin pages both need them), mutation-level `AI_SETTINGS` overrides for
writes
- `AgentChatResolver` & `AgentChatSubscriptionResolver` — already `AI`
- `AiGenerateTextController` — already `AI`
- Workspace AI config fields in `workspace.service.ts` — already
`AI_SETTINGS`

## Test plan

- [ ] As a user with `Ask AI` only (no `AI_SETTINGS`): chat tabs, "new
chat" button, and chat history pane are visible on desktop + mobile;
sending a message works; no `THREAD_NOT_FOUND` errors
- [ ] As a user with `AI_SETTINGS` but no `Ask AI`: chat UI is hidden
- [ ] As a user with `Ask AI` only: calling `skills` / `createSkill` /
`agentTurns` / `runEvaluationInput` via GraphQL returns permission
denied
- [ ] As an admin (`AI_SETTINGS`): skill settings and agent eval pages
still work
2026-05-28 20:21:58 +02:00
Paul RastoinandGitHub ebfaca5b3d EncryptedString PlaintextString branded string types (#21001)
## Summary

closes https://github.com/twentyhq/core-team-issues/issues/2464

Introduces compile-time branded types to distinguish encrypted
ciphertext from plaintext strings, preventing mix-ups like the one fixed
in #20819 — but at the type level rather in addition to the one existing
at runtime.

### Branded string primitives

- Created `EncryptedString` and `PlaintextString` as hard nominal brands
using `z.string().brand(...)`, making them non-assignable to each other
or to raw `string`
- Created `isEncryptedString` type predicate to narrow `string` to
`EncryptedString` based on the `enc:v2:` envelope prefix
- Retyped `SecretEncryptionService`: `encryptVersioned` accepts
`PlaintextString`, `decryptVersioned` returns `PlaintextString`

### Entity typing

- Typed encrypted columns across entities:
`SigningKeyEntity.privateKey`,
`TwoFactorAuthenticationMethodEntity.secret`,
`ApplicationRegistrationVariableEntity.encryptedValue`,
`ApplicationVariableEntity.value`
- Parameterized JSONB types for connected account connection parameters
(`ImapSmtpCaldavParams<Pwd>`) with reusable aliases
`EncryptedImapSmtpCaldavParams` / `DecryptedImapSmtpCaldavParams`
- Typed DTOs (`CreateApplicationRegistrationVariableInput`,
`UpdateApplicationRegistrationVariablePayload`,
`UpdateApplicationVariableEntityInput`) with `PlaintextString`

### ApplicationVariable always-encrypt uniformization

- Retyped `ApplicationVariableEntity.value` to `EncryptedString | ''` —
all values are now encrypted regardless of `isSecret`
- Updated `ApplicationVariableEntityService` to always encrypt on write
and always decrypt on read
- Simplified `UpdateApplicationVariableActionHandlerService` by removing
conditional encrypt/decrypt-on-isSecret-toggle logic
- Added slow instance command (`2.9.0`) to backfill-encrypt existing
`isSecret=false` plaintext rows and tighten the `CHECK` constraint

### ConfigStorageService refactor

- Split `convertAndSecureValue` (which used `any`) into two well-typed
methods: `convertAndDecrypt` and `convertAndEncrypt`
- Introduced `isSensitiveStringValue` type predicate to narrow values
before encryption/decryption

### What's next
- Typeorm entity derivation to strictly type sitemap configuration as
code + handler logic for encryption rotation
- https://github.com/twentyhq/core-team-issues/issues/2465
2026-05-28 17:41:16 +02:00
WeikoandGitHub 9b54200d8c Fix playwright CI (#21024)
## Context
The Install Playwright step ran npx playwright install with no
arguments, which downloads all browsers (Chromium + Firefox + WebKit +
ffmpeg, ~500MB+) on every run with no caching.

Fix:
- Install Chromium only — npx playwright install chromium instead of all
browsers.
- Cache the browser binaries — actions/cache on ~/.cache/ms-playwright,
keyed on the resolved Playwright version (v4-playwright-browsers-${{
runner.os }}-<version>). On a cache hit the install step is skipped
entirely; the cache invalidates automatically when the Playwright
version bumps.
2026-05-28 14:19:58 +00:00
bd6811d060 i18n - translations (#21022)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-05-28 15:59:42 +02:00
b32249877a i18n - translations (#21021)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-05-28 15:55:32 +02:00
martmullandGitHub 6fb6ef4e7a Add darkmode for oAuth screen (#21005)
## after

<img width="1512" height="828" alt="image"
src="https://github.com/user-attachments/assets/71664eab-c921-45da-ac67-7a660c976d5c"
/>
2026-05-28 15:48:57 +02:00
Raphaël BosiandGitHub bb4e28904f Support the "Me" filter for workspace members in dashboard widgets and add multi select (#20971)
Fixes https://github.com/twentyhq/twenty/issues/20225

The "Me" filter (current workspace member) worked in view filters but
not in dashboard widget filters — the server never resolved the
placeholder, and the widget side-panel UI had no "Me" option and only
allowed single selection.

Backend: `ChartDataQueryService` now forwards the current workspace
member id (from authContext) into filterValueDependencies, so the shared
filter logic resolves "Me" the same way it does for view filters. Added
unit tests for the converter.

Frontend: new multi-select picker for workspace member filters in the
widget side panel, mirroring the view filter's actor select: search
input, "Me" pinned item, and a multi-select workspace member list.

## Before
<img width="3024" height="1488" alt="CleanShot 2026-05-27 at 17 16
36@2x"
src="https://github.com/user-attachments/assets/b2cff46c-53e5-4e8a-a463-b106daf96c8c"
/>

## After
<img width="3024" height="1488" alt="CleanShot 2026-05-27 at 17 14
05@2x"
src="https://github.com/user-attachments/assets/8b3b5f11-44b9-4ae5-a2f3-9c7a689f4bb2"
/>
2026-05-28 15:43:47 +02:00
c3d1af89ae i18n - docs translations (#21019)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-05-28 15:43:18 +02:00
Raphaël BosiandGitHub e6221c5f0e Fix twenty sdk billing exports (#21016)
Fix two omissions from #19973 that prevented `twenty-sdk/billing` from
being a fully exported subpath:

- `package.json`: add `billing` to `typesVersions` (every other subpath
was listed; billing was the only one missing, breaking type resolution
for consumers using classic TS moduleResolution).
- `project.json`: add the billing vite build and `dist/billing` output
to the `build:sdk` target
2026-05-28 15:41:24 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
6566a918af chore(deps): bump @apollo/client from 4.1.6 to 4.2.0 (#20993)
Bumps [@apollo/client](https://github.com/apollographql/apollo-client)
from 4.1.6 to 4.2.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/apollographql/apollo-client/releases">@​apollo/client's
releases</a>.</em></p>
<blockquote>
<h2><code>@​apollo/client</code><a
href="https://github.com/4"><code>@​4</code></a>.2.0</h2>
<h3>Minor Changes</h3>
<ul>
<li>
<p><a
href="https://redirect.github.com/apollographql/apollo-client/pull/13132">#13132</a>
<a
href="https://github.com/apollographql/apollo-client/commit/f3ce805425d10a9666218a8e109288a2d46dcab1"><code>f3ce805</code></a>
Thanks <a
href="https://github.com/phryneas"><code>@​phryneas</code></a>! -
Introduce &quot;classic&quot; and &quot;modern&quot; method and hook
signatures.</p>
<p>Apollo Client 4.2 introduces two signature styles for methods and
hooks. All signatures previously present are now &quot;classic&quot;
signatures, and a new set of &quot;modern&quot; signatures are added
alongside them.</p>
<p><strong>Classic signatures</strong> are the default and are identical
to the signatures before Apollo Client 4.2, preserving backward
compatibility. Classic signatures still work with manually specified
TypeScript generics (e.g.,
<code>useSuspenseQuery&lt;MyData&gt;(...)</code>). However, manually
specifying generics has been discouraged for a long time—instead, we
recommend using <code>TypedDocumentNode</code> to automatically infer
types, which provides more accurate results without any manual
annotations.</p>
<p><strong>Modern signatures</strong> automatically incorporate your
declared <code>defaultOptions</code> into return types, providing more
accurate types. Modern signatures infer types from the document node and
do not support manually passing generic type arguments; TypeScript will
produce a type error if you attempt to do so.</p>
<p>Methods and hooks automatically switch to modern signatures the
moment any non-optional property is declared in
<code>DeclareDefaultOptions</code>. The switch happens across all
methods and hooks globally:</p>
<pre lang="ts"><code>// apollo.d.ts
import &quot;@apollo/client&quot;;
declare module &quot;@apollo/client&quot; {
  namespace ApolloClient {
    namespace DeclareDefaultOptions {
      interface WatchQuery {
errorPolicy: &quot;all&quot;; // non-optional → modern signatures
activated automatically
      }
    }
  }
}
</code></pre>
<p>Users can also manually switch to modern signatures without declaring
any <code>defaultOptions</code>, for example when wanting accurate type
inference without relying on global <code>defaultOptions</code>:</p>
<pre lang="ts"><code>// apollo.d.ts
import &quot;@apollo/client&quot;;
declare module &quot;@apollo/client&quot; {
  export interface TypeOverrides {
    signatureStyle: &quot;modern&quot;;
  }
}
</code></pre>
<p>Users can do a global <code>DeclareDefaultOptions</code> type
augmentation and then manually switch back to &quot;classic&quot; for
migration purposes:</p>
<pre lang="ts"><code>// apollo.d.ts
import &quot;@apollo/client&quot;;
declare module &quot;@apollo/client&quot; {
  export interface TypeOverrides {
    signatureStyle: &quot;classic&quot;;
  }
}
</code></pre>
</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/apollographql/apollo-client/blob/main/CHANGELOG.md">@​apollo/client's
changelog</a>.</em></p>
<blockquote>
<h2>4.2.0</h2>
<h3>Minor Changes</h3>
<ul>
<li>
<p><a
href="https://redirect.github.com/apollographql/apollo-client/pull/13132">#13132</a>
<a
href="https://github.com/apollographql/apollo-client/commit/f3ce805425d10a9666218a8e109288a2d46dcab1"><code>f3ce805</code></a>
Thanks <a
href="https://github.com/phryneas"><code>@​phryneas</code></a>! -
Introduce &quot;classic&quot; and &quot;modern&quot; method and hook
signatures.</p>
<p>Apollo Client 4.2 introduces two signature styles for methods and
hooks. All signatures previously present are now &quot;classic&quot;
signatures, and a new set of &quot;modern&quot; signatures are added
alongside them.</p>
<p><strong>Classic signatures</strong> are the default and are identical
to the signatures before Apollo Client 4.2, preserving backward
compatibility. Classic signatures still work with manually specified
TypeScript generics (e.g.,
<code>useSuspenseQuery&lt;MyData&gt;(...)</code>). However, manually
specifying generics has been discouraged for a long time—instead, we
recommend using <code>TypedDocumentNode</code> to automatically infer
types, which provides more accurate results without any manual
annotations.</p>
<p><strong>Modern signatures</strong> automatically incorporate your
declared <code>defaultOptions</code> into return types, providing more
accurate types. Modern signatures infer types from the document node and
do not support manually passing generic type arguments; TypeScript will
produce a type error if you attempt to do so.</p>
<p>Methods and hooks automatically switch to modern signatures the
moment any non-optional property is declared in
<code>DeclareDefaultOptions</code>. The switch happens across all
methods and hooks globally:</p>
<pre lang="ts"><code>// apollo.d.ts
import &quot;@apollo/client&quot;;
declare module &quot;@apollo/client&quot; {
  namespace ApolloClient {
    namespace DeclareDefaultOptions {
      interface WatchQuery {
errorPolicy: &quot;all&quot;; // non-optional → modern signatures
activated automatically
      }
    }
  }
}
</code></pre>
<p>Users can also manually switch to modern signatures without declaring
any <code>defaultOptions</code>, for example when wanting accurate type
inference without relying on global <code>defaultOptions</code>:</p>
<pre lang="ts"><code>// apollo.d.ts
import &quot;@apollo/client&quot;;
declare module &quot;@apollo/client&quot; {
  export interface TypeOverrides {
    signatureStyle: &quot;modern&quot;;
  }
}
</code></pre>
<p>Users can do a global <code>DeclareDefaultOptions</code> type
augmentation and then manually switch back to &quot;classic&quot; for
migration purposes:</p>
<pre lang="ts"><code>// apollo.d.ts
import &quot;@apollo/client&quot;;
declare module &quot;@apollo/client&quot; {
  export interface TypeOverrides {
    signatureStyle: &quot;classic&quot;;
  }
}
</code></pre>
</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/apollographql/apollo-client/commit/e010bdd239b5c10415d4b70ca791467cde12fc88"><code>e010bdd</code></a>
Version Packages (<a
href="https://redirect.github.com/apollographql/apollo-client/issues/13241">#13241</a>)</li>
<li><a
href="https://github.com/apollographql/apollo-client/commit/9c4c01a640b43bfb47bd52b25d5881c4ad7bec71"><code>9c4c01a</code></a>
Release 4.2 (<a
href="https://redirect.github.com/apollographql/apollo-client/issues/13129">#13129</a>)</li>
<li><a
href="https://github.com/apollographql/apollo-client/commit/222838e99bc6054120cc1f881bb225b1ef049de9"><code>222838e</code></a>
Exit prerelease mode</li>
<li><a
href="https://github.com/apollographql/apollo-client/commit/7d3a533c811a8423536ceeebccc06413ded5b6a3"><code>7d3a533</code></a>
Merge branch 'main' into release-4.2</li>
<li><a
href="https://github.com/apollographql/apollo-client/commit/f20d591bbf74cb4f0d87ec9a14b93a59fe46b039"><code>f20d591</code></a>
chore(deps): update actions/create-github-app-token digest to d72941d
(<a
href="https://redirect.github.com/apollographql/apollo-client/issues/13239">#13239</a>)</li>
<li><a
href="https://github.com/apollographql/apollo-client/commit/d4a28b6142e47164c8a24bd8c05a8aa3f1ce4eee"><code>d4a28b6</code></a>
chore(deps): pin dependencies (<a
href="https://redirect.github.com/apollographql/apollo-client/issues/13237">#13237</a>)</li>
<li><a
href="https://github.com/apollographql/apollo-client/commit/c1f39cf5402b052ab92886a1857840a745aee02b"><code>c1f39cf</code></a>
ci: pin Actions@SHA and disable cache on workflows with elevated OIDC
permiss...</li>
<li><a
href="https://github.com/apollographql/apollo-client/commit/511048b7bd6253a38a6b7ebe58e9674a39c74273"><code>511048b</code></a>
Event-based refetching docs (<a
href="https://redirect.github.com/apollographql/apollo-client/issues/13228">#13228</a>)</li>
<li><a
href="https://github.com/apollographql/apollo-client/commit/d1f68f1a5fdb7c6915a72b2426cad373a0526c06"><code>d1f68f1</code></a>
Version Packages (rc) (<a
href="https://redirect.github.com/apollographql/apollo-client/issues/13234">#13234</a>)</li>
<li><a
href="https://github.com/apollographql/apollo-client/commit/f1b541fed4111028b6842727178288156582e669"><code>f1b541f</code></a>
Prepare for rc release (<a
href="https://redirect.github.com/apollographql/apollo-client/issues/13232">#13232</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/apollographql/apollo-client/compare/@apollo/client@4.1.6...@apollo/client@4.2.0">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@apollo/client&package-manager=npm_and_yarn&previous-version=4.1.6&new-version=4.2.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-28 15:12:52 +02:00
martmullandGitHub 6ea637d6c5 Export STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS (#21010)
fix https://discord.com/channels/1130383047699738754/1509086323464474705
2026-05-28 12:51:01 +00:00
Félix MalfaitandGitHub 531410f64a fix(ai): expose MORPH_RELATION join columns in AI/MCP tool schemas (#21012)
## Summary

- Fixes a bug where `noteTarget` (and any other morph-relation join
object) created via AI/MCP would land with `targetCompanyId` /
`targetPersonId` / `targetOpportunityId` left null, even though the tool
reported success.
- Root cause: the Zod schema generators for the AI tools only branched
on `FieldMetadataType.RELATION`. MORPH_RELATION fields fell through to
the default case — for `create_*` they were exposed as `targetCompany:
string` instead of `targetCompanyId: uuid`, and for `group_by_*` they
were silently skipped entirely. Downstream
(`data-arg-processor.service.ts` and the group-by arg processor) already
accept the join-column form for both kinds of relations via
`computeMorphOrRelationFieldJoinColumnName` and
`isMorphOrRelationFlatFieldMetadata`, so the fix is purely in the schema
generators.

## Changes

- `record-properties.zod-schema.ts` — extend the existing RELATION
MANY_TO_ONE / ONE_TO_MANY branches to also match MORPH_RELATION.
- `group-by-tool.zod-schema.ts` — replace the silent MORPH_RELATION skip
with the same treatment as RELATION MANY_TO_ONE (exposes `${name}Id` as
a groupBy option).
- `test/integration/ai/suites/mcp-tool-execution.integration-spec.ts` —
new file. First integration test for tool execution end-to-end. Drives
the real MCP JSON-RPC endpoint with the seeded API key (`learn_tools`
for schema introspection, `execute_tool` for invocation):
- asserts `create_note_target`'s schema exposes `targetCompanyId` /
`targetPersonId` / `targetOpportunityId` as UUIDs and does **not**
expose `targetCompany` / `targetPerson` / `targetOpportunity`.
- creates a company + note + noteTarget via MCP, then queries the
workspace schema to confirm `targetCompanyId` is actually persisted in
the FK column.
- asserts `group_by_note_targets` schema accepts `targetCompanyId` as a
groupBy key.
- sets up 3 noteTargets (2 → company A, 1 → company B), calls
`group_by_note_targets` by `targetCompanyId`, and asserts the counts.

Out of scope: `record-filter.zod-schema.ts` has the same pattern (only
RELATION) — left for a follow-up so this PR stays focused on what was
reported.

## Test plan

- [x] `npx nx typecheck twenty-server`
- [x] `npx oxlint --type-aware` on changed files — clean
- [x] `npx oxfmt --check` on changed files — clean
- [x] Integration tests pass (4/4) after `database:reset`:
- `should expose the morph-relation join columns as \`${name}Id\` UUID
parameters`
- `should persist targetCompanyId when create_note_target is invoked via
MCP`
  - `should expose targetCompanyId as a valid groupBy option`
  - `should group noteTargets by targetCompanyId via MCP`
2026-05-28 14:17:10 +02:00
182960051f i18n - translations (#21013)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-05-28 13:30:39 +02:00
Félix MalfaitandGitHub 7d9f9605a2 feat(settings): move email handles and emailing domains to dedicated Email page (#21008)
## Summary

Both **Email Handles** and **Emailing Domains** were rendered on the
General workspace settings page, but they're workspace-level *email
infrastructure* (inbound shared addresses + outbound sender
authentication) and don't belong with the workspace name, picture, and
domain config.

- New `SettingsWorkspaceEmail` page at `/settings/email`
- Nav item under **Workspace**, hidden when `IS_EMAIL_GROUP_ENABLED` is
off (and gated by `WORKSPACE` permission)
- Related sub-routes (`email-group/:messageChannelId`,
`emailing-domain/:domainId`, etc.) moved from `general/` to `email/` so
the URL space stays consistent with the page
- General page now only contains name, picture, workspace domain, and
the delete-workspace section

No behavior changes to the underlying section components — they're
imported as-is into the new page.

## Test plan

- [ ] With `IS_EMAIL_GROUP_ENABLED` enabled: **Email** appears in the
Workspace nav and the page renders both sections
- [ ] With the flag disabled: **Email** is hidden from nav; navigating
to `/settings/email` directly renders nothing
- [ ] General page no longer shows Email Handles / Emailing Domains
- [ ] Clicking a shared inbox row navigates to
`/settings/email/email-group/:id` (was `general/...`)
- [ ] "Add emailing domain" navigates to
`/settings/email/emailing-domain/new`

## Notes

- Pre-existing `twenty-front` typecheck error in
`FrontComponentRendererProvider.tsx` (React types mismatch between
sibling packages) reproduces on `main` and is unrelated to this PR.
2026-05-28 13:22:55 +02:00
Félix MalfaitandGitHub 7065441972 fix: refresh admin panel feature flags after toggling (#21007)
## Summary

- Add `refetchQueries` to the `updateWorkspaceFeatureFlag` mutation in
the admin workspace detail page so the toggle reflects the new value
after toggling.
- Rename `useFeatureFlagState` → `useAdminUpdateFeatureFlag` since the
hook lives under `admin-panel` and is only consumed by the admin
workspace detail page.

## Bug

In the admin panel, toggling a feature flag for a workspace other than
the admin's own workspace sent the backend mutation successfully, but
the toggle in the UI remained unchanged.

The displayed value is derived from:
```tsx
const currentWorkspaceValue =
  currentWorkspace?.id === workspaceId
    ? currentWorkspace?.featureFlags?.find((f) => f.key === flag.key)?.value
    : undefined;
const displayedValue = currentWorkspaceValue ?? flag.value;
```

When viewing a different workspace, `currentWorkspaceValue` is
`undefined` so the toggle reads `flag.value` from the
`WORKSPACE_LOOKUP_ADMIN_PANEL` query. That query was never refetched
after the mutation, so the displayed value stayed stale.

The existing optimistic Jotai update on `currentWorkspaceState` still
runs — it is needed so the rest of the app (anything consuming
`useIsFeatureEnabled`) reacts immediately when an admin toggles a flag
on their own workspace.

## Test plan

- [ ] Open the admin panel → pick a workspace that is not your own →
Feature Flags tab → toggle a flag → toggle visually flips after the
mutation completes.
- [ ] Same flow on your own workspace → toggle flips, and any UI gated
on that flag also reacts.
- [ ] If the mutation fails, the toggle reverts (existing `onError`
rollback path).
2026-05-28 12:23:26 +02:00
6e2cf4cbc0 Fix lambda timeout diagnostics (#21002)
Trying to fix
https://twenty-v7.sentry.io/issues/7420384466/?environment=prod&environment=prod-eu&project=4507072499810304&query=is%3Aunresolved%20%21issue.type%3A%5Bperformance_consecutive_db_queries%2Cperformance_consecutive_http%2Cperformance_file_io_main_thread%2Cperformance_db_main_thread%2Cperformance_n_plus_one_db_queries%2Cperformance_n_plus_one_api_calls%2Cperformance_p95_endpoint_regression%2Cperformance_slow_db_query%2Cperformance_render_blocking_asset_span%2Cperformance_uncompressed_assets%2Cperformance_http_overhead%2Cperformance_large_http_payload%5D%20timesSeen%3A%3E10&referrer=issue-stream&sort=date

## Summary
- Prevent invoking Lambda functions stuck in `Pending` state by checking
`Configuration.State === 'Active'` in `checkLambdaExecutorBuildStatus` —
a Pending function now goes through `ensureLambdaExecutor` which waits
for Active.
- Track execution phase (`build`/`fetch-code`/`invoke`) via a
`LambdaExecutionPhase` enum and include phase timing + function state in
all error messages for faster debugging.

Co-authored-by: Matt Van Horn <mvanhorn@users.noreply.github.com>
2026-05-28 09:06:33 +00:00
de7daaa81a fix: exclude system objects and workflow/dashboard from AI/MCP write tool descriptors (#20973)
## Summary

fix: exclude system join objects from AI/MCP create/update/delete tool
descriptors

Closes #20403

---
AI was used for assistance.

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
2026-05-27 20:01:11 +02:00
c0cbe67bcd i18n - translations (#20982)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-05-27 19:47:07 +02:00
neo773GitHubFélix Malfaitclaude[bot] <41898282+claude[bot]@users.noreply.github.com>Félix Malfait
c5606212f2 Ses outbound followup (#20610)
This pull request unifies outbound with inbound under the new feature
and the new email groups feature.

These are workspace level shared inboxes that are shared between all
workspace members.

outbound sending with SES works, we only listen for tenant status
events, rest is managed by AWS

PR refactors old code and webhook to be split for outbound and inbound
for proper separation


| Area | Change |
|---|---|
| AWS SES driver | Split into `AwsSesRegisterDomainService` (tenant +
identity + DKIM + MAIL FROM + configuration-set + EventBridge dest +
contact list) and `AwsSesSendEmailService` (SendEmail). |
| Reputation webhook | New `/webhooks/messaging/ses/outbound` route. SES
→ EventBridge (`Sending Status Enabled/Disabled` on default bus) → SNS →
router → `SesOutboundSendingStateHandlerService` updates
`emailing_domain.tenantStatus`. |
| Inbound webhook | Refactored into `SesInboundWebhookRouterService` +
`SesInboundMailHandlerService`. Shared `SnsSignatureVerifierService` +
`SnsSubscriptionConfirmerService` across both routes. |
| Global uniqueness | New migration + instance command:
`emailing_domain.domain` is now globally unique (one tenant per domain
across workspaces). |
| Tenant status | New `emailing_domain.tenantStatus` column (`ACTIVE` /
`PAUSED`) + `EmailingDomainTenantStatusService`. |
| Send-email mutation | New `sendEmailViaDomain` GraphQL mutation +
DTOs. |
| Cleanup | `EmailingDomainWorkspaceCleanupJob` wired into
`WorkspaceService.deleteWorkspace` — tears down SES tenant association +
identity on workspace delete. |
| Settings UI | Rewritten around reusable `SettingsTableListSection`.
"Email Group" → "Email Handle" rename. New cells for
status/source/forwarding. Outbound domains surfaced on workspace
settings page. |

### Env vars (new)

All in `config-variables.ts`, group `AWS_SES_SETTINGS`, all optional:

- `AWS_SES_REGION` — `@IsAWSRegion`, consumed by `AwsSesClientProvider`
+ driver factory
- `AWS_SES_ACCOUNT_ID` — used for ARN construction in driver factory
- `SES_SNS_TOPIC_ARN_ALLOWLIST` — **shared** by inbound + outbound
webhook routers, comma-separated list of accepted SNS topic ARNs
(verified via `sns-payload-validator`)

### Migrations

- `1778862608620-add-emailing-domain-tenant-status` (fast) — adds
`tenantStatus` column.
- `1778865501791-unique-emailing-domain-globally` (slow, idempotent) —
enforces global uniqueness on `domain`.
- Instance commands bumped to `2.5`.

### Infra dependency

Two coupled twenty-infra PRs:

- `ses-inbound-email` — receipt-rule + inbound SNS topic + S3 bucket
policy + KMS grant + `email_group_*` outputs.
- `ses-outbound-tf` — EventBridge rule + outbound SNS topic + SES IAM
policy + outbound `webhook_url` subscription. **Based on
`ses-inbound-email`.**

Merge order: inbound first, then outbound. Outbound PR's chart edit owns
the comma-joined `SES_SNS_TOPIC_ARN_ALLOWLIST` value (both ARNs).


Features lives under `/settings/general`

<img width="1496" height="845" alt="SCR-20260519-ofhi-2"
src="https://github.com/user-attachments/assets/a025485a-09f7-4131-91cd-0067690ff18d"
/>

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Félix Malfait <FelixMalfait@users.noreply.github.com>
2026-05-27 19:38:44 +02:00
0702e72e3f i18n - translations (#20979)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-05-27 19:18:37 +02:00
martmullandGitHub 0503aa7982 Display application info in workflow side panel (#20976)
To comply this design
https://www.figma.com/design/xt8O9mFeLl46C5InWwoMrN/Twenty?node-id=99421-167676&t=GXw0bOgp2P51YVKi-0


## Before

<img width="569" height="470" alt="image"
src="https://github.com/user-attachments/assets/c52c8775-2934-4620-8885-dff5a8230a89"
/>


## After

<img width="314" height="258" alt="image"
src="https://github.com/user-attachments/assets/df9cbd15-cf7b-4a47-ac8d-1292c71a7c94"
/>
2026-05-27 16:57:48 +00:00
f98c5d0609 i18n - translations (#20978)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-05-27 19:01:41 +02:00
Félix MalfaitandGitHub 4797d2f270 feat(twenty-orm): introduce WorkspaceScopedRepository for core/metadata workspace-scoped entities (#20953)
## Summary

Adds a third tenancy enforcement layer for entities that live in shared
schemas (`core`, `metadata`) and carry a `workspaceId` column —
previously the only safeguard at this layer was developer discipline
(remembering to put `workspaceId` in every WHERE clause).

### The three layers, after this PR

| Layer | Scope | How it's enforced |
|---|---|---|
| 1. Workspace data | per-workspace schema (companies, people, custom
objects) | `twentyORMManager.getRepository(workspace, E)` — physical
isolation (own data source) |
| 2. Metadata | shared `metadata` schema (objectMetadata, fieldMetadata,
views, roles…) | Flat-entity-maps cache — workspace-scoped in-memory
map, lookups by id within it |
| 3. Core (new) | shared `core` schema (agent threads/turns/messages,
app tokens, etc.) | `WorkspaceScopedRepository<T>` — `workspaceId` is a
required positional argument on every read/write |

## What's in the PR

### The wrapper
(`packages/twenty-server/src/engine/twenty-orm/workspace-scoped-repository/`)
- `WorkspaceScopedRepository<T extends WorkspaceScopedEntity>` — wraps a
TypeORM `Repository<T>`, requires `workspaceId` on every
`find`/`findOne`/`findOneOrFail`/`update`/`delete`/`softDelete`/`insert`/`save`/`count`
call, merging it into the WHERE or stamping it on the entity.
`createQueryBuilder` is an explicit escape hatch (caller scopes
manually).
- Provided via Nest DI with
`@InjectWorkspaceScopedRepository(EntityClass)` and the
`provideWorkspaceScopedRepository(EntityClass)` provider factory.
- 19 unit tests cover the merge behavior, override-on-conflict, and the
array-where (OR) case.

### Lint enforcement
(`packages/twenty-oxlint-rules/rules/prefer-workspace-scoped-repository.ts`)
- New `twenty/prefer-workspace-scoped-repository` rule (level:
**error**).
- Blacklist of entity names: raw `@InjectRepository(E)` is rejected if
`E` is on the list.
- Initial list: `AgentTurnEntity`, `AgentMessageEntity`,
`AgentMessagePartEntity`, `AgentChatThreadEntity`,
`AgentTurnEvaluationEntity`, `AgentEntity`.
- Designed to grow over time as more consumers are migrated.
- 5 rule tests.

### Migration in this PR
All consumers of the six blacklisted entities, including:
- AI agent / chat / monitor resolvers, services, and jobs
- `AgentService`, `AiAgentRoleService`, `AiAgentWorkflowAction`,
`ApplicationService`, `WorkspaceFlatAgentMapCacheService`
- Admin-panel chat (migrated where the lookup is workspace-known; one
documented `eslint-disable` on the threadId-discovery lookup that
necessarily precedes the `allowImpersonation` permission check)
- `AiAgentRoleService` unit spec updated to mock the scoped wrapper

## Future work (deliberately not in this PR)

A standalone audit identified ~14 additional `core`/`metadata` entities
with `workspaceId` that currently use raw `@InjectRepository` and could
be added to the blacklist. Notable candidates: `UserWorkspaceEntity` (42
sites), `AppTokenEntity` (10), `FileEntity` (7),
`BillingCustomerEntity`/`BillingSubscriptionEntity` (~22 combined). Each
should be its own PR — the migration is mechanical but the surface is
wide.

## Test plan
- [x] `npx nx typecheck twenty-server` — clean
- [x] `npx nx lint twenty-server` — 0 warnings, 0 errors
- [x] `npx jest workspace-scoped-repository` — 19/19 pass
- [x] `npx nx test twenty-oxlint-rules` — 215/215 pass
- [x] `npx jest src/engine/metadata-modules/ai` — 44/44 pass
- [ ] Manual smoke: end-to-end AI agent chat send/receive (reviewer)
- [ ] Manual smoke: AI agent monitor — list turns, run evaluation
(reviewer)
- [ ] Manual smoke: admin-panel chat thread inspection (reviewer)
2026-05-27 18:52:53 +02:00
Raphaël BosiandGitHub c8b9dace72 Fix focus in front components inputs (#20961)
Fixes https://github.com/twentyhq/twenty/issues/20714

Fixes keyboard hotkey conflicts when typing inside `<input>` /
`<textarea>` elements rendered by Front Components. Editable fields
rendered through the component renderer now properly push/pop a focus
item onto Twenty's focus stack, disabling global keyboard hotkeys while
the user is typing.

## Before


https://github.com/user-attachments/assets/2003c2cb-2698-480f-aedf-bb2f30396572


## After


https://github.com/user-attachments/assets/2c7c6cb0-ecd7-4557-a77b-4d1f264345f0
2026-05-27 16:30:49 +00:00
martmullandGitHub fbae66de8a Fix error when token invalid (#20972)
## Before

<img width="914" height="519" alt="image"
src="https://github.com/user-attachments/assets/8933bf9e-d8db-4670-ad08-69e900083fc1"
/>

## After

<img width="1105" height="523" alt="image"
src="https://github.com/user-attachments/assets/d8638dbb-adee-4b7d-9d29-1a2a0185ab4f"
/>
2026-05-27 16:30:14 +00:00
46e7f23df1 fix(contact-creation): handle common email display-name shapes when auto-creating People (#20639)
## Summary

When messages are imported, Twenty auto-creates a Person record for any
recipient that doesn't exist yet. The display-name parser used at that
point is `displayName.split(' ')[0] / [1]`, which silently mangles
several common header shapes:

| Header | Old result |

|-------------------------------------------------|-----------------------------------------|
| `"Doe, John" <...>` | `firstName="Doe,"`, `lastName="John"` |
| `"John.Doe Doe" <...>` | `firstName="John.Doe"`, `lastName="Doe"`|
| `"Mary Jane Watson" <...>` | `lastName="Jane"` ("Watson" dropped) |
| `"john.doe@x.com" <john.doe@x.com>` (forwarder) | full address in
`firstName` |
| `"Doe, John:GROUP" <...>` (group-tag servers) |
`firstName="John:GROUP"` |

This PR rewrites `getFirstNameAndLastNameFromHandleAndDisplayName` to
handle each pattern. Behaviour in order:

1. Trim + strip wrapping quotes
2. Swap `"Last, First"` comma form
3. Fall back to handle parsing when display name contains `@` (real
names don't)
4. Split single dotted tokens (`"john.doe"` → `"John"`, `"Doe"`)
5. Preserve multi-word last names (`tokens.slice(1).join(' ')`)
6. De-synthesize dot-glued first names (`"John.Doe Doe"` → `"John"`,
`"Doe"`)
7. Strip `:XXX` trailing tag suffix from each parsed field

## Test plan

- [x] 16 new unit test cases covering each shape
(`__tests__/get-first-name-and-last-name-from-handle-and-display-name.util.spec.ts`)
- [x] Lint + typecheck clean
- [ ] No regression in the messaging import flow

---------

Co-authored-by: neo773 <62795688+neo773@users.noreply.github.com>
Co-authored-by: neo773 <neo773@protonmail.com>
2026-05-27 15:49:46 +00:00
6f3541fd7c i18n - translations (#20975)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-05-27 17:47:07 +02:00
nitinandGitHub 863f3f29a2 Fix page layout widget tab moves (#20915)
Fixes widget moves between page layout tabs by making pageLayoutTabId
part of the flat-entity diff, so the save mutation no longer silently
drops the new tab assignment.

https://discord.com/channels/1130383047699738754/1508737039128985680
2026-05-27 15:29:12 +00:00
martmullandGitHub 9264480824 Display which remote is used when twenty-sdk runs command (#20969)
## After
<img width="669" height="186" alt="image"
src="https://github.com/user-attachments/assets/c6ce25c9-35a9-4b2f-a473-59803b8931cc"
/>
2026-05-27 15:23:17 +00:00
nitinandGitHub 5ff8c3b219 revert(navigation-drawer): unwanted desktop design changes from #20634 (#20955)
Follow-up to #20634. Removes three desktop design changes that bled in
unintentionally:

- `font-weight: regular` → restored to `medium` on nav item labels
- `MenuItemIconBoxContainer` wrap around bare icons → removed
- Section title `padding-right/top` tweak → restored to original values

Mobile-specific fixes from #20634 (slide-over drawer width, min-width
overflow fixes, breadcrumb cleanup, etc.) are preserved.
2026-05-27 15:06:45 +00:00
Thomas TrompetteandGitHub ff28547a36 tt-fix-logic-function-error-handling (#20928)
## Summary
- **Fix `callWithTimeout` timer leak**: the `setTimeout` was never
cleared when the callback resolved first, leaving orphaned timers (up to
15 minutes) in the Node.js event loop. Now uses `try/finally` with
`clearTimeout`.
- **Properly classify timeout errors**: introduced
`ExecutionTimedOutError` so the Lambda driver can throw
`LOGIC_FUNCTION_EXECUTION_TIMEOUT` instead of
`LOGIC_FUNCTION_EXECUTION_FAILED` — users now see "Function execution
timed out" instead of the generic "Function execution failed."

## Test plan
- [x] Execute a logic function normally and verify it still works
- [x] Trigger a logic function timeout and verify the error message says
"Function execution timed out" (not "Function execution failed")
- [x] Verify that a deleted logic function invocation logs a specific
"was deleted" warning
- [x] Verify no orphaned timers remain after logic function execution
completes
2026-05-27 14:32:12 +00:00
Félix MalfaitandGitHub dde6df7a26 refactor(website): replace axios with native fetch (#20967)
## Summary

Follow-up to #20966 (Stripe fetch client). axios's default Node http
adapter on workerd has the same TLS hang the Stripe SDK had — it just
doesn't surface today because all three call sites are wrapped in
\`unstable_cache(revalidate: 3600)\` and the cache is populated at build
time, so misses are rare and the failure mode is a silent \`null\` to
the layout.

This swaps the three remaining axios calls in \`twenty-website\` for
native \`fetch\` and removes axios from
\`packages/twenty-website/package.json\`. The package is still used by
other workspaces, so yarn.lock keeps the other resolution.

Touched call sites:
- \`src/lib/releases/fetch-latest-release-tag.ts\` (GitHub releases —
runs at build time, cosmetic)
- \`src/lib/community/fetch-github-star-count.ts\` (GitHub star count in
menu)
- \`src/lib/community/fetch-discord-member-count.ts\` (Discord member
count in menu)

## Test plan

- [ ] After merge + deploy: confirm GitHub star + Discord member counts
render in the site menu (non-zero, formatted)
- [ ] Confirm \`/releases\` shows the latest tag-gated visible release
notes
- [ ] No \`axios\` in worker bundle (\`grep axios .open-next/worker.js\`
should be empty)
2026-05-27 16:31:39 +02:00
DeviSriSaiCharanandGitHub 92b0e07617 fix: separate initial timeline loading from fetchMore loading state (#20896)
Fixes: #20742 

# Issue:
In the timeline activity inside the side panel, when we scroll down it
fetches more data and it displays a skeleton and after fetching finishes
the scroll position always jumps back to the top. Because of this, we
have to scroll to the bottom again to load more data.



https://github.com/user-attachments/assets/40d99df7-bdfb-4351-bc4f-baec2a035f13

## Root Cause
`useTimelineActivities` exposed a single `loading` state from
`useFindManyRecords`, which became true for both:
- Initial timeline fetch
- Pagination / fetchMore requests

`loadingTimelineActivities` becomes true whenever a network request is
triggered, including pagination requests where timeline records are
already available.

Because of this, the UI could not distinguish between the first query
loading state and subsequent fetchMore loading states.

## Fix
Added a separate firstQueryLoading state to detect only the first
timeline request.

The first query is identified by checking:

- the request is still loading
- and no timeline activities have been loaded yet

Once activities are already available, any future loading state is
treated as pagination/loading more records instead of initial loading.

This allows the UI to correctly handle:

- Skeleton loaders for first load
- Infinite scroll loaders for pagination
- Empty states after loading finishes



https://github.com/user-attachments/assets/48e8e078-82e2-43d8-823f-2f71e4f4f6e1
2026-05-27 14:20:59 +00:00
951ead5a1e fix(front): ignore IME composition Enter in input hotkeys (#20958)
## What

Pressing Enter to confirm an IME (CJK) composition no longer submits the
input. The Enter / Escape / Tab handlers now ignore key events fired
while a composition is in progress (`isComposing`, or the legacy
`keyCode === 229`).

Fixes #20954

## Why

`isComposing` was not checked anywhere in `twenty-front`, so the Enter
that confirms a Japanese / Chinese / Korean conversion was also consumed
as a submit / escape / tab hotkey — making it very hard to type CJK text
into any input that submits on Enter.

## Changes

- `useHotkeysOnFocusedElement` — central guard; covers every input wired
through `useRegisterInputEvents` (~13 components) and all hotkeys routed
through this hook.
- Direct `onKeyDown` Enter handlers: `CreateWorkspace`,
`SettingsDevelopersApiKeysNew`, `SettingsAccountsBlocklistInput`.

## Notes

- No effect on non-IME (Latin) typing — `isComposing` is only true
during an active composition. It also improves accented / dead-key input
on Latin layouts.
- `react-hotkeys-hook@4` does not handle IME composition on its own, so
the guard is explicit.

## Testing

Manually verified with a Japanese IME on Chrome (macOS) against the
v2.8.3 self-hosted image: romaji + Enter now only confirms the
conversion; a second Enter on committed text submits as expected. The
GIF in #20954 shows the original buggy behavior.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:09:10 +00:00
Félix MalfaitandGitHub e79f43813b fix(website): use Stripe fetch client on Cloudflare Workers (#20966)
## Summary

Every `/api/enterprise/*` route on `twenty-website-prod` is currently
timing out at exactly 80s with `Request aborted due to timeout being
reached (80000ms)` — that's `Stripe.DEFAULT_TIMEOUT` aborting itself,
not Cloudflare.

The Stripe SDK's default transport uses Node's `http`/`https` module.
Under workerd, even with `nodejs_compat`, the outbound TLS connection to
`api.stripe.com` hangs and the SDK eventually times out at its built-in
80s ceiling. Worked on EKS (real Node), breaks on Cloudflare Workers.

Fix is one line: pass `httpClient: Stripe.createFetchHttpClient()` so
the SDK uses workerd's native `fetch` instead of the polyfilled Node
transport.

## Blast radius

`getStripeClient()` is shared across every enterprise route. All of
these are silently broken on prod right now:

- `POST /api/enterprise/checkout` (confirmed in logs)
- `POST /api/enterprise/portal`
- `POST /api/enterprise/seats`
- `GET  /api/enterprise/status`
- `POST /api/enterprise/activate`
- `POST /api/enterprise/validate`

Single change in `stripe-client.ts` unblocks all of them.

## Test plan

- [ ] After merge + deploy to prod: `wrangler tail twenty-website-prod`
while hitting the enterprise checkout flow; same call should complete in
<2s instead of 80s
- [ ] Verify a real Stripe checkout session is created (Stripe dashboard
→ Payments → Checkout sessions)
- [ ] Spot-check `/api/enterprise/status` and `/api/enterprise/portal`
are no longer timing out
2026-05-27 16:03:48 +02:00
73ab5de46c i18n - translations (#20963)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-05-27 14:59:02 +02:00
MarieandGitHub 986b9dcb3d Deprecate dummy enterprise key 1/2 (#20890)
Remove usage of hasValidEnterpriseKey in FE (replaced by
hasValidSignedEnterpriseKey)

To avoid breaking change at deploy time, we will wait until after this
has been deployed in prod, to remove hasValidEnterpriseKey in the BE.
2026-05-27 12:34:48 +00:00
ae7db23bdd i18n - translations (#20959)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-05-27 14:33:57 +02:00
WeikoandGitHub 2f358a1775 Add a Table display mode to relation field widgets (#20929)
## Context

Adds a new Table layout to the FIELD widget for to-many relation fields.
On a record page, a relation can now be displayed as a full record table
(the same component used for record indexes and dashboard table widgets)
scoped to the records related to the current record.



https://github.com/user-attachments/assets/320b24dc-f019-4d0e-bc71-3e64d032d75a



https://github.com/user-attachments/assets/2f6d4f8e-de26-4fc1-ae12-c9b9c19654dc



https://github.com/user-attachments/assets/3fb6d512-f83c-4818-823e-46ad2644fbc2
2026-05-27 12:13:58 +00:00
EtienneandGitHub fc3004b2f6 fix(website) - fix getPartners (#20947)
**Context**
Fix empty marketplace at /partners/list: page was being statically
prerendered with getPartners() returning [] (build-time fetch failure),
then served from OpenNext's R2 cache forever — so the partners API was
never actually called in production.

**Change**
Wrap getPartners in unstable_cache with revalidate: 300, matching the
existing fetchGithubStarCount / fetchDiscordMemberCount ISR pattern.
After deploy, the cached empty result expires within 5 minutes and the
worker refetches from the partners API at runtime (where the env vars
actually exist), populating the page. Also drops the now-redundant
cache: 'no-store' from partnersApiFetch.
2026-05-27 10:53:17 +00:00
086b79e5b9 i18n - translations (#20952)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-05-27 11:02:42 +02:00
Félix MalfaitandGitHub 8cb88cabee fix(role): rebind API keys + agents before deleting their role (#20935)
## Customer-reported bug

A customer hit this when using the AI chat:

```json
{
  "message": "API key 760d4822-da40-4b3f-9031-40563d7ed6c9 has no role assigned",
  "extensions": {
    "code": "INTERNAL_SERVER_ERROR",
    "userFriendlyMessage": "This API key has no role assigned."
  }
}
```

Their integration authenticates via API key. Somewhere along the way,
the role bound to that API key was deleted, leaving the API key
authenticated but role-less. Any request that hits a permission check
(`getRoleIdForApiKeyId`) blows up.

## Root cause

In `RoleService.deleteManyRoles`, the pre-deletion cleanup
(`assignDefaultRoleToMembersWithRoleToDelete`) only rebinds **user
workspaces** to the workspace default role. API keys and agents pointing
at the role are ignored. Because `RoleTargetEntity.role` declares
`onDelete: 'CASCADE'`, the FK then drops the role_target rows for those
API keys / agents — but the API keys themselves stay in `api_key`, now
orphaned in `apiKeyRoleMap`.

A previous read-side workaround
([2767ddac44](https://github.com/twentyhq/twenty/commit/2767ddac44) —
make the `role` ResolveField nullable) handled the API-key-details page,
but did not address the write paths (`getRoleIdForApiKeyId`).

## Fix

- Rename `assignDefaultRoleToMembersWithRoleToDelete` →
`rebindTargetsOfRoleToDeleteToDefaultRole` and extend it to rebind API
keys (via `ApiKeyRoleService.assignRoleToApiKey`) and agents (via
`AiAgentRoleService.assignRoleToAgent`) in the same step, before the
role is deleted.
- If the workspace default role doesn't satisfy `canBeAssignedToApiKeys`
/ `canBeAssignedToAgents`, the inner `assignRoleTo*` validation throws.
We catch that and rethrow as a `PermissionsException` with a
role-deletion-context message and two new codes —
`ROLE_CANNOT_BE_ASSIGNED_TO_API_KEYS` /
`ROLE_CANNOT_BE_ASSIGNED_TO_AGENTS` — so the admin sees a clear
"reassign these first" prompt rather than a confusing inner error.

## Scope / non-goals

- **Already-orphaned API keys are not auto-healed.** The customer still
needs to reassign a role to their existing orphan API key via the UI
(Settings > API Keys > [the key] > role). A separate cleanup command for
existing orphans is a follow-up.
- I did not investigate *why* the customer's session was authenticated
via API key in the AI chat — that may be their integration setup. Worth
confirming with them separately.

## Test plan

- [ ] Workspace with default role `Admin` (which has
`canBeAssignedToApiKeys: true`): create an API key with a custom role,
delete the custom role → API key is rebound to Admin, requests keep
working.
- [ ] Workspace with default role `Member` (default, has
`canBeAssignedToApiKeys: false`): create an API key with a custom role,
delete the custom role → role deletion fails with the new
`ROLE_CANNOT_BE_ASSIGNED_TO_API_KEYS` error explaining the admin must
reassign first. API key + custom role are both unchanged.
- [ ] Same two scenarios for agents (`canBeAssignedToAgents`).
- [ ] Existing user-workspace rebind behavior is unchanged.
- [ ] Role deletion with no dependent API keys / agents still works.
2026-05-27 10:54:02 +02:00
Félix MalfaitGitHubclaude[bot] <41898282+claude[bot]@users.noreply.github.com>
ac89d2ff56 feat: raise FILES field max number of values from 10 to 60 (#20950)
## Summary

Raises the artificial hardcoded ceiling on `maxNumberOfValues` for
custom FILES fields from `10` to `60` so users can attach more files per
record.

- Bumped `FILES_FIELD_MAX_NUMBER_OF_VALUES` constant in `twenty-shared`
from `10` to `60`
- Updated validator unit test (inline snapshots + "exceeds max" case)
- Updated create/update files-field metadata integration tests and Jest
snapshots

The frontend Zod schema only enforces a `min`, so no frontend changes
are required — the backend constant is the single source of truth for
the upper bound.

Refs #20942

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
2026-05-27 10:53:09 +02:00
423faa6153 i18n - translations (#20951)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-05-27 10:40:15 +02:00
martmullandGitHub a051490ec9 Basic app logo fixes (#20919)
as title, took the quick win fixes from
https://github.com/twentyhq/twenty/pull/20909/changes#diff-3367344412b2f44f0273d8019c1bc36396198244b9558d02921b135f62522baaR180
and leave the main fix for later as it requires an architectural update
2026-05-27 08:21:35 +00:00
1805 changed files with 73539 additions and 31446 deletions
+44
View File
@@ -0,0 +1,44 @@
name: CI Codex Plugin
on:
push:
branches:
- main
pull_request:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
changed-files-check:
uses: ./.github/workflows/changed-files.yaml
with:
files: |
package.json
packages/twenty-codex-plugin/**
.github/workflows/ci-codex-plugin.yaml
codex-plugin-validate:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 10
runs-on: ubuntu-latest
steps:
- name: Fetch local actions
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Codex Plugin / Validate
run: npx nx run twenty-codex-plugin:validate
- name: Codex Plugin / Test
run: npx nx run twenty-codex-plugin:test
@@ -80,10 +80,20 @@ jobs:
with:
name: storybook-twenty-front-component-renderer
path: packages/twenty-front-component-renderer/storybook-static
- name: Resolve Playwright version
id: playwright-version
run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> "$GITHUB_OUTPUT"
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ~/.cache/ms-playwright
key: v4-playwright-browsers-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}
- name: Install Playwright
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: |
cd packages/twenty-front-component-renderer
npx playwright install
npx playwright install chromium
- name: Serve storybook & run tests
run: |
npx http-server packages/twenty-front-component-renderer/storybook-static --port 6008 --silent &
+11 -1
View File
@@ -96,10 +96,20 @@ jobs:
with:
name: storybook-static
path: packages/twenty-front/storybook-static
- name: Resolve Playwright version
id: playwright-version
run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> "$GITHUB_OUTPUT"
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ~/.cache/ms-playwright
key: v4-playwright-browsers-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}
- name: Install Playwright
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: |
cd packages/twenty-front
npx playwright install
npx playwright install chromium
- name: Front / Write .env
run: npx nx reset:env twenty-front
- name: Serve storybook & run tests
+1
View File
@@ -51,6 +51,7 @@ dump.rdb
mcp.json
/.junie/
/.agents/plugins/marketplace.json
TRANSLATION_QA_REPORT.md
.playwright-mcp/
.playwright-cli/
+159
View File
@@ -0,0 +1,159 @@
# Twenty Website — DESIGN.md
> Visual system for the Twenty marketing site. Distilled from `packages/twenty-website/src/theme/`. Loaded by every `impeccable` invocation alongside PRODUCT.md.
## Theme
**Light by default.** A founder browsing a partner profile in daylight on a 1427 inch monitor is the default scene. The site does ship a `data-scheme="dark"` override (see `css-variables.ts`), but no current public page opts into it. Treat dark as a deferred surface.
## Color
Palette is OKLCH-equivalent neutrals at the surface level. The brand accents (blue, pink, yellow, green) are present in the token system but used sparingly — none of them appear on the partner pages.
### Strategy: Restrained
Tinted neutrals + one accent ≤10%. The accent for partner pages is the deep ink black (`var(--color-black-100)`) used in CTAs and hover states. Anything beyond a hairline border, an icon glyph, or a primary CTA should question whether it needs color at all.
### Tokens (from `src/theme/colors.ts` + `css-variables.ts`)
Neutrals (the workhorses):
| Token | Hex (computed) | Role |
| --- | --- | --- |
| `colors.primary.background[100]` | `#ffffff` | Page + card surface |
| `colors.primary.text[100]` | `#1c1c1c` | Headlines, primary text |
| `colors.primary.text[80]` | `#1c1c1ccc` | Body text |
| `colors.primary.text[60]` | `#1c1c1c99` | Eyebrows, meta, captions |
| `colors.primary.text[40]` | `#1c1c1c66` | Disabled / placeholder |
| `colors.primary.text[20]` | `#1c1c1c33` | Subtle separators |
| `colors.primary.text[10]` | `#1c1c1c1a` | Hairline borders |
| `colors.primary.text[5]` | `#1c1c1c0d` | Subtle fills (rates panel, skill chips) |
| `colors.primary.border[10]` | `#1c1c1c1a` | Default border |
| `colors.primary.border[20]` | `#1c1c1c33` | Hover border |
Reverse palette (for dark CTAs):
| Token | Role |
| --- | --- |
| `colors.secondary.background[100]` | Filled CTA background (deep ink) |
| `colors.secondary.text[100]` | Filled CTA text (white) |
Brand accents (currently absent from partner pages; available if needed):
- `colors.accent.blue``#4a38f5` / `#8174f8`
- `colors.accent.pink``#ed87fc` / `#f3abfd`
- `colors.accent.yellow``#feffb7` / `#feffd9`
- `colors.accent.green``#89fc9a` / `#b0fdbe`
- `colors.highlight` — same hue as blue accent
**Do not introduce gradients, glass blurs, or saturated fills on partner pages.** Color is conviction here, not decoration.
## Typography
Three families, each load-balanced via CSS variables:
| Family | Var | Use |
| --- | --- | --- |
| `theme.font.family.serif` | `--font-serif` | Headlines, partner names, headline values |
| `theme.font.family.sans` | `--font-sans` | Body, prose, interactive labels |
| `theme.font.family.mono` | `--font-mono` | Eyebrows, meta, currency labels, tabular numerics |
| `theme.font.family.retro` | `--font-retro` | Reserved (not used on partner pages) |
### Weight + Size Contrast
Weights: `light: 300`, `regular: 400`, `medium: 500`. No bold. Hierarchy is driven by scale and family contrast, never by weight alone.
Scale (`theme.font.size(n)``calc(var(--font-base) * n)`, where `--font-base: 0.25rem` ≈ 4px):
- Display / h1: size 912 (3648px)
- h2 / section heads: size 78 (2832px)
- h3 / card heads: size 56 (2024px)
- Body / prose: size 45 (1620px)
- Eyebrow / meta: size 3 (12px) with `letter-spacing: 0.060.08em` and `text-transform: uppercase`
Body line length: cap at 6575ch (the existing `PartnerProfileIntro` uses `max-width: 62ch` — keep that order of magnitude).
### Hierarchy contract
- A serif `<h1>` at size 9 light reads as a partner's name on the detail page.
- A mono eyebrow above or below it locates the partner (region · city · country).
- A serif size 6 light reads as a section head.
- Body prose is sans regular.
- Currency values are serif (they read as headline numbers, not stats).
- Currency labels and meta are mono.
## Spacing & Layout
Base unit `4px`. Spacing helper `theme.spacing(n)` returns `n * 4px`. Common rhythms on the partner pages:
- Inter-section gap on the detail page: `theme.spacing(1014)` — generous, editorial breathing room.
- Inter-element gap inside a section: `theme.spacing(35)`.
- Card padding: `theme.spacing(6)`.
- Page horizontal padding: `theme.spacing(4)` mobile, `theme.spacing(10)` ≥ md breakpoint.
### Radius
`theme.radius(n)` returns `n * 2px`. The default card radius is `theme.radius(2)` = 4px. Pills use `999px`. No softer rounding than that.
### Borders
Borders are hairline (`1px solid theme.colors.primary.border[10]`). They define edges quietly. On hover they step to `border[20]`. Never use a chunky border as decoration.
## Components
### Card (PartnerCard, RatesPanel)
White surface, hairline border, 4px radius, 24px padding, soft shadow on hover only:
```css
background-color: ${theme.colors.primary.background[100]};
border: 1px solid ${theme.colors.primary.border[10]};
border-radius: ${theme.radius(2)};
padding: ${theme.spacing(6)};
&:hover {
border-color: ${theme.colors.primary.border[20]};
box-shadow: 0 12px 32px -16px rgba(0, 0, 0, 0.18);
transform: translateY(-2px);
}
```
### Chip / Pill
Rounded `999px`, 1px border, subtle background fill (`primary.text[5]` for filter pills, transparent for chip rows), `text[80]` color, mono or sans font.
### Button / LinkButton
Lives in `@/design-system/components`. Two color modes: `primary` (deep ink fill, white text) and `secondary` (transparent fill, ink text + 1px border). `variant="contained"` is what partner pages use.
### Avatar
`PartnerAvatar` is a deterministic generated mark from name + slug. Used as fallback when `profilePictureUrl` is missing. The real photo overlays it at 120px circle on the detail page, 56px on the list card.
## Motion
- Hover transitions: 250ms, ease-out (cubic-bezier curve in `PartnerCard`: `0.25s ease`).
- Card entrance: 700ms cubic-bezier `0.22, 1, 0.36, 1` (ease-out-quart), 90ms stagger per index.
- All motion respects `@media (prefers-reduced-motion: reduce)` — animations stop, hover translate disabled.
- **No bounce, no elastic, no parallax.** Editorial restraint.
## Iconography
`@tabler/icons-react`, 1416px on body-level chips, 1824px on buttons. Always `aria-hidden="true"` when decorative. Stroke width `2` (default).
## Accessibility Defaults
- Focus ring: `outline: 2px solid theme.colors.primary.text[100]; outline-offset: 4px` (already used on the card link).
- Touch target ≥ 40×40px on mobile.
- `aria-label` on icon-only buttons, `aria-labelledby` on sectioned regions.
- All `<a target="_blank">` includes `rel="noopener noreferrer"`.
- Color is never the sole carrier of meaning. The money pills carry both an icon and a text label.
## Anti-patterns (project-specific)
In addition to the impeccable shared absolute bans:
- **Do not use the brand accent colors (blue/pink/yellow/green) on partner pages** unless we have a stronger reason than "to add color".
- **No skeuomorphic shadows on cards.** The hover shadow is `0 12px 32px -16px rgba(0,0,0,0.18)` — that's the ceiling.
- **No gradients on anything.** Including text, borders, and backgrounds.
- **No floating "Trusted by" logo bars** on partner pages.
+80
View File
@@ -0,0 +1,80 @@
# Twenty Website — Product & Brand Context
> Strategic context for design work on the Twenty marketing site (`packages/twenty-website`). Loaded by every `impeccable` invocation.
## Register
**Brand.** The marketing site is a public-facing surface where the design itself is part of the credibility argument. Prospects evaluate Twenty partly by how the site feels. The product app (`packages/twenty-front`) is a separate product-register surface, governed elsewhere.
## Users & Purpose
The primary audience varies by route, but the working assumption for partner-related pages is:
- **Who:** A budget-holding decision maker (founder, RevOps lead, or COO) shopping for a CRM implementation partner. Already on Twenty's site, evaluating a shortlist of partners.
- **Context:** Doing a side-by-side comparison across 25 candidates over a single browsing session. Will spend 3090 seconds on each profile before deciding whether to book a call.
- **Decision being made:** "Is this partner credible, the right size, the right specialty, and within budget? Do I trust them enough to commit 30 minutes to a discovery call?"
What the partner pages must do, in priority order:
1. Communicate credibility (real firm, real person, real work).
2. Surface fit signals fast (skills, region, languages, deployment expertise, budget range).
3. Give the visitor a confident "next step" affordance (book a call or vet via LinkedIn) without pressure.
## Desired Outcome
The redesign should make `/partners/profile/[slug]` feel like a *thoughtfully curated profile of a top-tier partner*, not a generic templated card. A visitor should leave thinking "this firm is serious" even if they don't book a call this session.
Specifically:
- **Confidence over information density.** A short, well-typeset profile beats a packed-but-busy one.
- **Editorial restraint.** White space, deliberate type hierarchy, and a few well-chosen details say more than dozens of small components.
- **Quiet conviction.** No hype copy, no growth-hack patterns, no "Trusted by" logo strips. The partner's own work and intro speak for themselves.
## Brand Personality
**Editorial · Founder-led · Considered.**
The site reads like a thoughtful indie publication, not a SaaS landing page. Serif headlines, plenty of whitespace, deliberate typographic rhythm. Quietly opinionated — Twenty has a point of view about CRM (open-source, customizable, well-designed) and the site reflects that without shouting.
Tonal anchors:
- Stripe's documentation for clarity, Linear's marketing for restraint, an editorial print magazine for typography choices.
## Anti-references
**Reject these patterns. They make the work read as generic AI / generic SaaS:**
- **Generic SaaS landing.** Big-number heroes, identical icon-grid cards, gradient text, navy + lime accent color schemes, "supercharge your workflow" language.
- **Corporate enterprise tone.** Stock photos of diverse handshakes. "Trusted by Fortune 500" logo strips as the primary credibility move. Trust-badge bars.
- **Bento templates.** Repetitive same-size cards. Vercel-style scroll-pin animations on every section.
- **Side-stripe borders, gradient text, glassmorphism, hero-metric templates, identical card grids** — see impeccable's shared absolute bans.
## Strategic Design Principles
1. **Typography carries the design.** The brand has a serif/sans/mono trio. Hierarchy is set by scale + weight contrast, not by color or borders.
2. **Restrained palette.** Tinted neutrals (black/white via CSS variables, with alpha-tone variants for text and borders) carry 90%+ of the surface. Accent color used sparingly when it appears at all.
3. **Whitespace is a feature.** Tight cards feel cheap. Pages should breathe.
4. **Asymmetry over grid.** A 12-col bento is the wrong shape for a profile page. Use asymmetric two-column layouts where one column does heavy lifting.
5. **One opinionated detail per page.** Each surface should have one moment of editorial conviction (a typographic flourish, a precise micro-interaction, a deliberate space) rather than five generic flourishes.
## Accessibility
**WCAG AA + keyboard + screen reader baseline:**
- All interactive elements reachable by keyboard, focus visible (`outline: 2px solid`, not just color shift).
- Semantic landmarks: `<header>`, `<main>`, `<nav>`, `<section aria-labelledby=…>`, headings in order.
- All images with informational content have alt text. Decorative icons have `aria-hidden="true"`.
- Body text ≥ 4.5:1 contrast; large text (≥18pt or 14pt bold) ≥ 3:1.
- Respect `prefers-reduced-motion`. Animations stop, don't slow.
- Forms have explicit labels. Errors are announced.
## Tech & Constraints
- Next.js 16 app router (Server Components by default, `'use client'` for interactivity).
- Linaria styled-components (`@linaria/react`) for zero-runtime CSS-in-JS.
- Lingui (`@lingui/react`) for i18n; never hardcode user-visible strings.
- Theme tokens in `packages/twenty-website/src/theme/`. Colors are CSS variables resolved to OKLCH-tinted neutrals.
- `@tabler/icons-react` for iconography (no Heroicons, no custom SVGs unless purposeful).
- `@radix-ui/react-*` for primitives (Popover etc) where headless behavior is needed.
## Out of Scope for This File
- Detailed visual tokens (colors, type scale, motion specs) live in `DESIGN.md`.
- Per-page IA decisions live in shape briefs (`docs/superpowers/specs/`).
Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

+2 -1
View File
@@ -28,7 +28,7 @@
"resolutions": {
"graphql": "16.8.1",
"type-fest": "4.10.1",
"typescript": "5.9.2",
"typescript": "5.9.3",
"nodemailer": "8.0.4",
"graphql-redis-subscriptions/ioredis": "^5.6.0",
"@lingui/core": "5.1.2",
@@ -63,6 +63,7 @@
"packages/twenty-client-sdk",
"packages/twenty-cli",
"packages/create-twenty-app",
"packages/twenty-codex-plugin",
"packages/twenty-oxlint-rules",
"packages/twenty-companion",
"packages/twenty-claude-skills"
+1 -1
View File
@@ -50,7 +50,7 @@
"jest": "29.7.0",
"jest-environment-node": "^29.4.1",
"twenty-shared": "workspace:*",
"typescript": "^5.9.2",
"typescript": "^5.9.3",
"vite": "^7.0.0",
"vite-plugin-dts": "^4.5.4",
"vite-tsconfig-paths": "^4.2.1"
@@ -0,0 +1,11 @@
import { defineCommandMenuItem, objectMetadataItem } from 'twenty-sdk/define';
export default defineCommandMenuItem({
universalIdentifier: '7d3f2b9e-5c0a-4e8b-ad6e-2f9c3b4d5e6a',
frontComponentUniversalIdentifier: '6c2f1a8d-4b9e-4f7a-9c5d-1e8b2a3d4c5f',
label: 'Contributor Stats',
icon: 'IconChartBar',
isPinned: false,
conditionalAvailabilityExpression:
objectMetadataItem.nameSingular === 'contributor',
});
@@ -0,0 +1,11 @@
import { defineCommandMenuItem, objectMetadataItem } from 'twenty-sdk/define';
export default defineCommandMenuItem({
universalIdentifier: '4640992f-c2c9-4bba-b5df-9c8f05dc9e80',
frontComponentUniversalIdentifier: '08f40f82-24ed-4f3e-8c99-695151e90e38',
label: 'Fetch Contributors',
icon: 'IconUsers',
isPinned: false,
conditionalAvailabilityExpression:
objectMetadataItem.nameSingular === 'contributor',
});
@@ -1,10 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import {
enqueueSnackbar,
objectMetadataItem,
useRecordId,
} from 'twenty-sdk/front-component';
import { enqueueSnackbar, useRecordId } from 'twenty-sdk/front-component';
import { THEME } from 'src/modules/github/contributor/components/theme';
import { callAppRoute } from 'src/modules/shared/call-app-route';
@@ -682,12 +678,4 @@ export default defineFrontComponent({
description:
'Displays time-bucketed charts of PRs authored (merged only), merged and reviewed by a contributor over the last week, month, 3 months or year.',
component: ContributorStats,
command: {
universalIdentifier: '7d3f2b9e-5c0a-4e8b-ad6e-2f9c3b4d5e6a',
label: 'Contributor Stats',
icon: 'IconChartBar',
isPinned: false,
conditionalAvailabilityExpression:
objectMetadataItem.nameSingular === 'contributor',
},
});
@@ -2,7 +2,6 @@ import { useEffect, useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import {
enqueueSnackbar,
objectMetadataItem,
unmountFrontComponent,
updateProgress,
} from 'twenty-sdk/front-component';
@@ -95,12 +94,4 @@ export default defineFrontComponent({
'Fetches contributors from every configured GitHub repo (GITHUB_REPOS) and upserts them as Contributor records.',
isHeadless: true,
component: FetchContributors,
command: {
universalIdentifier: '4640992f-c2c9-4bba-b5df-9c8f05dc9e80',
label: 'Fetch Contributors',
icon: 'IconUsers',
isPinned: false,
conditionalAvailabilityExpression:
objectMetadataItem.nameSingular === 'contributor',
},
});
@@ -0,0 +1,11 @@
import { defineCommandMenuItem, objectMetadataItem } from 'twenty-sdk/define';
export default defineCommandMenuItem({
universalIdentifier: 'c34f56aa-ff65-43a4-9db2-774945dbcc53',
frontComponentUniversalIdentifier: '9430e4fc-9ecb-428d-9bde-2babeb1f452f',
label: 'Fetch Issues',
icon: 'IconBug',
isPinned: false,
conditionalAvailabilityExpression:
objectMetadataItem.nameSingular === 'issue',
});
@@ -2,7 +2,6 @@ import { useEffect, useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import {
enqueueSnackbar,
objectMetadataItem,
unmountFrontComponent,
updateProgress,
} from 'twenty-sdk/front-component';
@@ -98,12 +97,4 @@ export default defineFrontComponent({
description: 'Fetches issues from GitHub repos',
isHeadless: true,
component: FetchIssues,
command: {
universalIdentifier: 'c34f56aa-ff65-43a4-9db2-774945dbcc53',
label: 'Fetch Issues',
icon: 'IconBug',
isPinned: false,
conditionalAvailabilityExpression:
objectMetadataItem.nameSingular === 'issue',
},
});
@@ -0,0 +1,11 @@
import { defineCommandMenuItem, objectMetadataItem } from 'twenty-sdk/define';
export default defineCommandMenuItem({
universalIdentifier: '719cfe1c-d570-4c8c-89e6-88671c6ba1ea',
frontComponentUniversalIdentifier: '7c397b0c-8b19-4fac-924a-8f6aa1dece78',
label: 'Fetch Project Items',
icon: 'IconLayoutKanban',
isPinned: false,
conditionalAvailabilityExpression:
objectMetadataItem.nameSingular === 'projectItem',
});
@@ -2,7 +2,6 @@ import { useEffect, useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import {
enqueueSnackbar,
objectMetadataItem,
unmountFrontComponent,
updateProgress,
} from 'twenty-sdk/front-component';
@@ -97,12 +96,4 @@ export default defineFrontComponent({
description: 'Fetches project items from GitHub Projects V2',
isHeadless: true,
component: FetchProjectItems,
command: {
universalIdentifier: '719cfe1c-d570-4c8c-89e6-88671c6ba1ea',
label: 'Fetch Project Items',
icon: 'IconLayoutKanban',
isPinned: false,
conditionalAvailabilityExpression:
objectMetadataItem.nameSingular === 'projectItem',
},
});
@@ -0,0 +1,11 @@
import { defineCommandMenuItem, objectMetadataItem } from 'twenty-sdk/define';
export default defineCommandMenuItem({
universalIdentifier: '0b24e6d6-da0c-4c0e-8d88-5bfd7f3cd75a',
frontComponentUniversalIdentifier: '5e2ba8df-1db5-4964-94d3-44cccfd791a0',
label: 'Fetch Pull Requests',
icon: 'IconGitPullRequest',
isPinned: false,
conditionalAvailabilityExpression:
objectMetadataItem.nameSingular === 'pullRequest',
});
@@ -2,7 +2,6 @@ import { useEffect, useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import {
enqueueSnackbar,
objectMetadataItem,
unmountFrontComponent,
updateProgress,
} from 'twenty-sdk/front-component';
@@ -101,12 +100,4 @@ export default defineFrontComponent({
description: 'Fetches pull requests and reviews from GitHub repos',
isHeadless: true,
component: FetchPrs,
command: {
universalIdentifier: '0b24e6d6-da0c-4c0e-8d88-5bfd7f3cd75a',
label: 'Fetch Pull Requests',
icon: 'IconGitPullRequest',
isPinned: false,
conditionalAvailabilityExpression:
objectMetadataItem.nameSingular === 'pullRequest',
},
});
@@ -1,6 +1,6 @@
{
"name": "twenty-partners",
"version": "0.3.1",
"version": "0.3.4",
"license": "MIT",
"engines": {
"node": "^24.5.0",
@@ -14,6 +14,7 @@
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json .",
"test": "vitest run",
"test:unit": "vitest run --config vitest.unit.config.ts",
"test:watch": "vitest",
"seed": "tsx src/scripts/seed.ts",
"seed:prod": "ENV_FILE=.env.prod tsx src/scripts/seed.ts",
@@ -22,11 +23,14 @@
"import:dryrun": "tsx src/scripts/import-from-tft.ts",
"import:dryrun:prod": "ENV_FILE=.env.prod tsx src/scripts/import-from-tft.ts",
"import:apply": "IMPORT_APPLY=1 tsx src/scripts/import-from-tft.ts",
"import:apply:prod": "ENV_FILE=.env.prod IMPORT_APPLY=1 tsx src/scripts/import-from-tft.ts"
"import:apply:prod": "ENV_FILE=.env.prod IMPORT_APPLY=1 tsx src/scripts/import-from-tft.ts",
"migrate:partner-scope": "tsx src/scripts/migrate-partner-scope.ts",
"migrate:partner-scope:prod": "ENV_FILE=.env.prod tsx src/scripts/migrate-partner-scope.ts"
},
"dependencies": {
"twenty-client-sdk": "2.4.0",
"twenty-sdk": "2.4.0"
"twenty-sdk": "2.4.0",
"zod": "^4.1.11"
},
"devDependencies": {
"@types/node": "^24.7.2",
@@ -10,4 +10,12 @@ export default defineApplication({
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
displayName: APP_DISPLAY_NAME,
description: APP_DESCRIPTION,
applicationVariables: {
PARTNER_APPLICATION_SECRET: {
universalIdentifier: '2026a052-9f01-4d18-b6a7-31c3a5b1c7d2',
description:
'Shared secret required in the X-Application-Secret header on POST /partner-applications. Must match the website route\'s PARTNER_APPLICATION_SECRET env var. Set per-workspace in Settings → Apps → Twenty Partners → Variables.',
isSecret: true,
},
},
});
@@ -0,0 +1,250 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { afterEach, beforeAll, describe, expect, it } from 'vitest';
import {
handler,
type SubmitPartnerApplicationInput,
type SubmitPartnerApplicationResult,
} from '../submit-partner-application.logic-function';
const TEST_SECRET = 'test-secret-abc123';
process.env.PARTNER_APPLICATION_SECRET = TEST_SECRET;
const client = new CoreApiClient();
const baseInput = (overrides: Partial<SubmitPartnerApplicationInput> = {}): SubmitPartnerApplicationInput => ({
firstName: 'Ada',
lastName: 'Lovelace',
email: 'ada.test@example.com',
companyName: 'Analytical Engines Ltd',
...overrides,
});
const authedEvent = (input: SubmitPartnerApplicationInput) => ({
body: input,
headers: { 'x-application-secret': TEST_SECRET },
});
const createdPartnerIds: string[] = [];
const createdPersonIds: string[] = [];
const createdCompanyIds: string[] = [];
async function cleanup(): Promise<void> {
for (const id of createdPartnerIds.splice(0)) {
await client.mutation({ destroyPartner: { __args: { id }, id: true } }).catch(() => {});
}
for (const id of createdPersonIds.splice(0)) {
await client.mutation({ destroyPerson: { __args: { id }, id: true } }).catch(() => {});
}
for (const id of createdCompanyIds.splice(0)) {
await client.mutation({ destroyCompany: { __args: { id }, id: true } }).catch(() => {});
}
}
async function trackCreated(result: SubmitPartnerApplicationResult): Promise<void> {
if (!result.ok) return;
createdPartnerIds.push(result.partnerId);
const fetched = await client.query({
partner: {
__args: { filter: { id: { eq: result.partnerId } } },
id: true,
company: { id: true },
persons: { edges: { node: { id: true } } },
},
});
const node = fetched.partner;
if (!node) return;
if (node.company) createdCompanyIds.push(node.company.id);
for (const edge of node.persons?.edges ?? []) createdPersonIds.push(edge.node.id);
}
beforeAll(async () => {
await client.query({ partners: { __args: { first: 1 }, edges: { node: { id: true } } } });
});
afterEach(async () => {
await cleanup();
});
describe('submit-partner-application handler — auth', () => {
it('returns unauthorized when the x-application-secret header is missing', async () => {
const result = await handler({ body: baseInput({ email: 'noauth@example.com' }), headers: {} });
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.reason).toBe('unauthorized');
});
it('returns unauthorized when the x-application-secret header is wrong', async () => {
const result = await handler({
body: baseInput({ email: 'badauth@example.com' }),
headers: { 'x-application-secret': 'nope' },
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.reason).toBe('unauthorized');
});
});
describe('submit-partner-application handler — upsert', () => {
it('creates Company, Person, and Partner on first submission and returns created: true', async () => {
const result = await handler(authedEvent(baseInput({ email: 'create.case@example.com', companyName: 'YC Agency' })));
await trackCreated(result);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.created).toBe(true);
expect(result.partnerId).toMatch(/^[0-9a-f-]{36}$/);
const partner = await client.query({
partner: {
__args: { filter: { id: { eq: result.partnerId } } },
id: true,
name: true,
slug: true,
validationStage: true,
reviewed: true,
partnerTier: true,
company: { id: true, name: true },
persons: { edges: { node: { id: true, name: { firstName: true, lastName: true }, emails: { primaryEmail: true } } } },
},
});
const node = partner.partner;
expect(node?.name).toBe('YC Agency');
expect(node?.slug).toBe('yc-agency');
expect(node?.validationStage).toBe('APPLICATION');
expect(node?.reviewed).toBe(false);
expect(node?.partnerTier).toBe('NEW');
expect(node?.company?.name).toBe('YC Agency');
expect(node?.persons?.edges).toHaveLength(1);
const personNode = node?.persons?.edges?.[0]?.node;
expect(personNode?.emails?.primaryEmail).toBe('create.case@example.com');
expect(personNode?.name?.firstName).toBe('Ada');
expect(personNode?.name?.lastName).toBe('Lovelace');
});
it('returns created: false and updates fields on resubmission for the same email', async () => {
const first = await handler(authedEvent(baseInput({ email: 'update.case@example.com', city: 'London' })));
await trackCreated(first);
expect(first.ok).toBe(true);
if (!first.ok) return;
const second = await handler(
authedEvent(
baseInput({
email: 'update.case@example.com',
city: 'Paris',
partnerScope: ['ADVISORY', 'SOLUTIONING'],
typeOfTeam: 'SOLO',
hourlyRate: 175,
}),
),
);
expect(second.ok).toBe(true);
if (!second.ok) return;
expect(second.created).toBe(false);
expect(second.partnerId).toBe(first.partnerId);
const partner = await client.query({
partner: {
__args: { filter: { id: { eq: second.partnerId } } },
id: true,
city: true,
partnerScope: true,
typeOfTeam: true,
hourlyRate: { amountMicros: true, currencyCode: true },
},
});
const node = partner.partner;
expect(node?.city).toBe('Paris');
expect(node?.partnerScope).toEqual(['ADVISORY', 'SOLUTIONING']);
expect(node?.typeOfTeam).toBe('SOLO');
expect(node?.hourlyRate).toEqual({ amountMicros: 175_000_000, currencyCode: 'USD' });
});
it('preserves staff-owned columns (validationStage, ranking, reviewed) on resubmission', async () => {
const first = await handler(authedEvent(baseInput({ email: 'staff.preserve@example.com' })));
await trackCreated(first);
expect(first.ok).toBe(true);
if (!first.ok) return;
await client.mutation({
updatePartner: {
__args: {
id: first.partnerId,
data: { validationStage: 'VALIDATED', reviewed: true, ranking: 'RATING_4' },
},
id: true,
},
});
const second = await handler(authedEvent(baseInput({ email: 'staff.preserve@example.com', city: 'Berlin' })));
expect(second.ok).toBe(true);
if (!second.ok) return;
const partner = await client.query({
partner: {
__args: { filter: { id: { eq: second.partnerId } } },
validationStage: true,
reviewed: true,
ranking: true,
city: true,
},
});
const node = partner.partner;
expect(node?.validationStage).toBe('VALIDATED');
expect(node?.reviewed).toBe(true);
expect(node?.ranking).toBe('RATING_4');
expect(node?.city).toBe('Berlin');
});
it('accepts new category values and stores applicationNotes', async () => {
const result = await handler(
authedEvent(
baseInput({
email: 'cat.test@example.com',
partnerScope: ['ADVISORY', 'SOLUTIONING'],
applicationNotes: 'Workspace https://app.twenty.com/ws · refs: Acme',
}),
),
);
expect(result.ok).toBe(true);
if (!result.ok) return;
await trackCreated(result);
const fetched = await client.query({
partner: {
__args: { filter: { id: { eq: result.partnerId } } },
partnerScope: true,
applicationNotes: true,
},
});
const node = fetched.partner;
expect(node?.partnerScope).toEqual(
expect.arrayContaining(['ADVISORY', 'SOLUTIONING']),
);
expect(node?.applicationNotes).toContain('Acme');
});
it('rejects a legacy scope value as invalid_input', async () => {
const result = await handler(
authedEvent(baseInput({ email: 'legacy@example.com', partnerScope: ['APPS'] })),
);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.reason).toBe('invalid_input');
});
it('returns ok: false on malformed input (empty email)', async () => {
const result = await handler(
authedEvent({
firstName: 'Ada',
lastName: 'Lovelace',
email: '',
companyName: 'Analytical Engines Ltd',
}),
);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.reason).toBe('invalid_input');
});
});
@@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest';
import { submitPartnerApplicationSchema } from '../submit-partner-application.logic-function';
const base = {
firstName: 'Ada',
lastName: 'Lovelace',
email: 'ada@example.com',
companyName: 'Analytical Engines',
};
describe('submitPartnerApplicationSchema', () => {
it('accepts a minimal valid application', () => {
expect(submitPartnerApplicationSchema.safeParse(base).success).toBe(true);
});
it('accepts the new partnerScope categories', () => {
const result = submitPartnerApplicationSchema.safeParse({
...base,
partnerScope: ['ADVISORY', 'SOLUTIONING', 'HOSTING'],
});
expect(result.success).toBe(true);
});
it('rejects legacy / unknown partnerScope values', () => {
expect(
submitPartnerApplicationSchema.safeParse({ ...base, partnerScope: ['APPS'] })
.success,
).toBe(false);
});
it('rejects an unknown typeOfTeam', () => {
expect(
submitPartnerApplicationSchema.safeParse({ ...base, typeOfTeam: 'FREELANCE' })
.success,
).toBe(false);
});
it('rejects an unknown country but accepts a known one with languages', () => {
expect(
submitPartnerApplicationSchema.safeParse({ ...base, country: 'ATLANTIS' })
.success,
).toBe(false);
expect(
submitPartnerApplicationSchema.safeParse({
...base,
country: 'FRANCE',
languages: ['ENGLISH', 'FRENCH'],
}).success,
).toBe(true);
});
it('requires a non-empty email and companyName', () => {
expect(
submitPartnerApplicationSchema.safeParse({ ...base, email: '' }).success,
).toBe(false);
expect(
submitPartnerApplicationSchema.safeParse({ ...base, companyName: '' })
.success,
).toBe(false);
});
it('accepts optional notes and commercials', () => {
const result = submitPartnerApplicationSchema.safeParse({
...base,
applicationNotes: 'Looking forward to partnering.',
hourlyRate: 150,
projectBudgetMin: 5000,
skills: ['React', 'PostgreSQL'],
});
expect(result.success).toBe(true);
});
});
@@ -0,0 +1,105 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { defineLogicFunction } from 'twenty-sdk/define';
export const GET_PARTNER_BY_SLUG_LOGIC_FUNCTION_ID =
'5e3e7b88-2cf2-4f56-9a4a-46c4c1d6b0bb';
type CurrencyValue = { amountMicros: number; currencyCode: string } | null;
type LinkValue = { primaryLinkUrl: string | null } | null;
type Partner = {
id: string;
name: string | null;
slug: string | null;
introduction: string | null;
languagesSpoken: string[] | null;
deploymentExpertise: string[] | null;
partnerScope: string[] | null;
region: string[] | null;
calendarLink: LinkValue;
hourlyRate: CurrencyValue;
projectBudgetMin: CurrencyValue;
linkedin: LinkValue;
profilePicture: LinkValue;
skills: string[] | null;
city: string | null;
country: string | null;
};
type GetPartnerBySlugResult =
| { ok: true; partner: Partner }
| { ok: false; reason: 'NOT_FOUND' | string };
const handler = async (input: {
queryStringParameters?: { slug?: string };
}): Promise<GetPartnerBySlugResult> => {
const slug = input?.queryStringParameters?.slug;
if (typeof slug !== 'string' || slug.length === 0) {
return { ok: false, reason: 'Missing slug query parameter' };
}
try {
const client = new CoreApiClient();
const result = await client.query({
partners: {
__args: {
filter: {
slug: { eq: slug },
validationStage: { eq: 'VALIDATED' },
availability: { eq: 'AVAILABLE' },
},
first: 1,
},
edges: {
node: {
id: true,
name: true,
slug: true,
introduction: true,
languagesSpoken: true,
deploymentExpertise: true,
partnerScope: true,
region: true,
calendarLink: { primaryLinkUrl: true },
hourlyRate: { amountMicros: true, currencyCode: true },
projectBudgetMin: { amountMicros: true, currencyCode: true },
linkedin: { primaryLinkUrl: true },
profilePicture: { primaryLinkUrl: true },
skills: true,
city: true,
country: true,
},
},
},
} as any);
const edges = (result?.partners?.edges ?? []) as Array<{ node: Partner }>;
const partner = edges[0]?.node;
if (!partner) {
return { ok: false, reason: 'NOT_FOUND' };
}
return { ok: true, partner };
} catch (err) {
return {
ok: false,
reason: err instanceof Error ? err.message : String(err),
};
}
};
export default defineLogicFunction({
universalIdentifier: GET_PARTNER_BY_SLUG_LOGIC_FUNCTION_ID,
name: 'get-partner-by-slug',
description:
'Returns a single VALIDATED + AVAILABLE partner by slug, or NOT_FOUND.',
timeoutSeconds: 10,
handler,
httpRouteTriggerSettings: {
path: '/partner-by-slug',
httpMethod: 'GET',
isAuthRequired: false,
},
});
@@ -4,53 +4,58 @@ import { defineLogicFunction } from 'twenty-sdk/define';
export const LIST_AVAILABLE_PARTNERS_LOGIC_FUNCTION_ID =
'0f91164f-f492-41e8-9bb0-481be5a3d5b9';
type Partner = {
id: string;
name: string | null;
slug: string | null;
introduction: string | null;
languagesSpoken: string[] | null;
deploymentExpertise: string[] | null;
region: string[] | null;
calendarLink: { primaryLinkUrl: string | null } | null;
};
// CoreApiClient is codegenerated from the synced workspace schema, so the query
// selection is strictly typed. Keep the fetch in one place and derive the
// response shape from it, so the HTTP contract can never drift from what we
// actually ask the API for.
const queryAvailablePartners = (client: CoreApiClient) =>
client.query({
partners: {
__args: {
filter: {
validationStage: { eq: 'VALIDATED' },
availability: { eq: 'AVAILABLE' },
slug: { neq: '' },
},
orderBy: [{ name: 'AscNullsLast' }],
first: 100,
},
edges: {
node: {
id: true,
name: true,
slug: true,
introduction: true,
languagesSpoken: true,
deploymentExpertise: true,
partnerScope: true,
region: true,
calendarLink: { primaryLinkUrl: true },
hourlyRate: { amountMicros: true, currencyCode: true },
projectBudgetMin: { amountMicros: true, currencyCode: true },
linkedin: { primaryLinkUrl: true },
profilePicture: { primaryLinkUrl: true },
skills: true,
city: true,
country: true,
},
},
},
});
type AvailablePartner = NonNullable<
Awaited<ReturnType<typeof queryAvailablePartners>>['partners']
>['edges'][number]['node'];
type ListAvailablePartnersResult =
| { ok: true; count: number; partners: Partner[] }
| { ok: true; count: number; partners: AvailablePartner[] }
| { ok: false; reason: string };
const handler = async (): Promise<ListAvailablePartnersResult> => {
try {
const client = new CoreApiClient();
const result = await client.query({
partners: {
__args: {
filter: {
validationStage: { eq: 'VALIDATED' },
availability: { eq: 'AVAILABLE' },
},
orderBy: [{ name: 'AscNullsLast' }],
first: 100,
},
edges: {
node: {
id: true,
name: true,
slug: true,
introduction: true,
languagesSpoken: true,
deploymentExpertise: true,
region: true,
calendarLink: { primaryLinkUrl: true },
},
},
},
} as any);
const partners = (
(result?.partners?.edges ?? []) as Array<{ node: Partner }>
).map((edge) => edge.node);
const result = await queryAvailablePartners(client);
const partners = (result.partners?.edges ?? []).map((edge) => edge.node);
return { ok: true, count: partners.length, partners };
} catch (err) {
@@ -0,0 +1,273 @@
import { CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core';
import { defineLogicFunction } from 'twenty-sdk/define';
import { z } from 'zod';
import { slugify } from '../scripts/slugify';
export const SUBMIT_PARTNER_APPLICATION_LOGIC_FUNCTION_ID =
'7b1e2c5f-3a14-4f7d-8e91-0b5e2a3c4d76';
const PARTNER_COUNTRY_VALUES = [
'AFGHANISTAN','ALBANIA','ALGERIA','ANDORRA','ANGOLA','ANTIGUA_AND_BARBUDA','ARGENTINA','ARMENIA','AUSTRALIA','AUSTRIA','AZERBAIJAN','BAHAMAS','BAHRAIN','BANGLADESH','BARBADOS','BELARUS','BELGIUM','BELIZE','BENIN','BHUTAN','BOLIVIA','BOSNIA_AND_HERZEGOVINA','BOTSWANA','BRAZIL','BRUNEI','BULGARIA','BURKINA_FASO','BURUNDI','CAMBODIA','CAMEROON','CANADA','CAPE_VERDE','CENTRAL_AFRICAN_REPUBLIC','CHAD','CHILE','CHINA','COLOMBIA','COMOROS','CONGO','DR_CONGO','COSTA_RICA','CROATIA','CUBA','CYPRUS','CZECH_REPUBLIC','DENMARK','DJIBOUTI','DOMINICA','DOMINICAN_REPUBLIC','ECUADOR','EGYPT','EL_SALVADOR','EQUATORIAL_GUINEA','ERITREA','ESTONIA','ESWATINI','ETHIOPIA','FIJI','FINLAND','FRANCE','GABON','GAMBIA','GEORGIA','GERMANY','GHANA','GREECE','GRENADA','GUATEMALA','GUINEA','GUINEA_BISSAU','GUYANA','HAITI','HONDURAS','HUNGARY','ICELAND','INDIA','INDONESIA','IRAN','IRAQ','IRELAND','ISRAEL','ITALY','IVORY_COAST','JAMAICA','JAPAN','JORDAN','KAZAKHSTAN','KENYA','KIRIBATI','KOSOVO','KUWAIT','KYRGYZSTAN','LAOS','LATVIA','LEBANON','LESOTHO','LIBERIA','LIBYA','LIECHTENSTEIN','LITHUANIA','LUXEMBOURG','MADAGASCAR','MALAWI','MALAYSIA','MALDIVES','MALI','MALTA','MARSHALL_ISLANDS','MAURITANIA','MAURITIUS','MEXICO','MICRONESIA','MOLDOVA','MONACO','MONGOLIA','MONTENEGRO','MOROCCO','MOZAMBIQUE','MYANMAR','NAMIBIA','NAURU','NEPAL','NETHERLANDS','NEW_ZEALAND','NICARAGUA','NIGER','NIGERIA','NORTH_KOREA','NORTH_MACEDONIA','NORWAY','OMAN','PAKISTAN','PALAU','PALESTINE','PANAMA','PAPUA_NEW_GUINEA','PARAGUAY','PERU','PHILIPPINES','POLAND','PORTUGAL','QATAR','ROMANIA','RUSSIA','RWANDA','SAINT_KITTS_AND_NEVIS','SAINT_LUCIA','SAINT_VINCENT','SAMOA','SAN_MARINO','SAO_TOME_AND_PRINCIPE','SAUDI_ARABIA','SENEGAL','SERBIA','SEYCHELLES','SIERRA_LEONE','SINGAPORE','SLOVAKIA','SLOVENIA','SOLOMON_ISLANDS','SOMALIA','SOUTH_AFRICA','SOUTH_KOREA','SOUTH_SUDAN','SPAIN','SRI_LANKA','SUDAN','SURINAME','SWEDEN','SWITZERLAND','SYRIA','TAIWAN','TAJIKISTAN','TANZANIA','THAILAND','TIMOR_LESTE','TOGO','TONGA','TRINIDAD_AND_TOBAGO','TUNISIA','TURKEY','TURKMENISTAN','TUVALU','UGANDA','UKRAINE','UNITED_ARAB_EMIRATES','UNITED_KINGDOM','UNITED_STATES','URUGUAY','UZBEKISTAN','VANUATU','VATICAN','VENEZUELA','VIETNAM','YEMEN','ZAMBIA','ZIMBABWE',
] as const;
const PARTNER_LANGUAGE_VALUES = [
'ENGLISH','FRENCH','GERMAN','SPANISH','PORTUGUESE','ITALIAN','DUTCH','ARABIC','CHINESE','JAPANESE','RUSSIAN','HINDI',
] as const;
const PARTNER_SCOPE_VALUES = [
'ADVISORY','SOLUTIONING','DEVELOPMENT','HOSTING','SUPPORT',
] as const;
const PARTNER_TYPE_OF_TEAM_VALUES = ['SOLO','AGENCY'] as const;
// The request contract. zod is the single source of truth: it validates the
// incoming body at runtime and the input type is inferred from it, so the two
// can never drift. Enum-valued fields are constrained to the same option sets
// the Partner object accepts.
export const submitPartnerApplicationSchema = z.object({
firstName: z.string().trim().min(1),
lastName: z.string(),
email: z.string().trim().min(1),
companyName: z.string().trim().min(1),
domainName: z.string().optional(),
linkedin: z.string().optional(),
city: z.string().optional(),
country: z.enum(PARTNER_COUNTRY_VALUES).optional(),
languages: z.array(z.enum(PARTNER_LANGUAGE_VALUES)).optional(),
typeOfTeam: z.enum(PARTNER_TYPE_OF_TEAM_VALUES).optional(),
partnerScope: z.array(z.enum(PARTNER_SCOPE_VALUES)).optional(),
skills: z.array(z.string()).optional(),
applicationNotes: z.string().optional(),
hourlyRate: z.number().optional(),
projectBudgetMin: z.number().optional(),
calendarLink: z.string().optional(),
});
export type SubmitPartnerApplicationInput = z.infer<
typeof submitPartnerApplicationSchema
>;
export type SubmitPartnerApplicationResult =
| { ok: true; created: boolean; partnerId: string }
| { ok: false; reason: string };
function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0;
}
function toMicros(usd: number | undefined): { amountMicros: number; currencyCode: 'USD' } | undefined {
if (typeof usd !== 'number' || !Number.isFinite(usd) || usd < 0) return undefined;
return { amountMicros: Math.round(usd * 1_000_000), currencyCode: 'USD' };
}
function buildApplicationNotes(input: SubmitPartnerApplicationInput): string | null {
return isNonEmptyString(input.applicationNotes) ? input.applicationNotes.trim() : null;
}
// Mirrors the subset of Partner{Create,Update}Input this handler writes. The
// enum-typed columns are narrowed from the validated string inputs below.
type PartnerFieldsForUpsert = {
name: string;
linkedin?: { primaryLinkUrl: string };
city?: string;
country?: CoreSchema.PartnerCountryEnum;
languagesSpoken?: CoreSchema.PartnerLanguagesSpokenEnum[];
typeOfTeam?: CoreSchema.PartnerTypeOfTeamEnum;
partnerScope?: CoreSchema.PartnerPartnerScopeEnum[];
skills?: string[];
hourlyRate?: { amountMicros: number; currencyCode: 'USD' };
projectBudgetMin?: { amountMicros: number; currencyCode: 'USD' };
calendarLink?: { primaryLinkUrl: string };
applicationNotes?: string | null;
};
function buildPartnerFields(input: SubmitPartnerApplicationInput): PartnerFieldsForUpsert {
const fields: PartnerFieldsForUpsert = {
name: input.companyName.trim(),
};
if (isNonEmptyString(input.linkedin)) fields.linkedin = { primaryLinkUrl: input.linkedin.trim() };
if (isNonEmptyString(input.city)) fields.city = input.city.trim();
// validate() has already checked these against the allowed value sets, so
// narrowing the validated strings to their enum types here is sound.
if (input.country !== undefined) fields.country = input.country as CoreSchema.PartnerCountryEnum;
if (input.languages !== undefined && input.languages.length > 0)
fields.languagesSpoken = input.languages as CoreSchema.PartnerLanguagesSpokenEnum[];
if (input.typeOfTeam !== undefined) fields.typeOfTeam = input.typeOfTeam as CoreSchema.PartnerTypeOfTeamEnum;
if (input.partnerScope !== undefined && input.partnerScope.length > 0)
fields.partnerScope = input.partnerScope as CoreSchema.PartnerPartnerScopeEnum[];
if (input.skills !== undefined && input.skills.length > 0) fields.skills = input.skills.filter(isNonEmptyString);
const hourly = toMicros(input.hourlyRate);
if (hourly) fields.hourlyRate = hourly;
const min = toMicros(input.projectBudgetMin);
if (min) fields.projectBudgetMin = min;
if (isNonEmptyString(input.calendarLink)) fields.calendarLink = { primaryLinkUrl: input.calendarLink.trim() };
const notes = buildApplicationNotes(input);
if (notes !== null) fields.applicationNotes = notes;
return fields;
}
type SubmitPartnerApplicationEvent = {
headers?: Record<string, string | undefined>;
body?: unknown;
};
const APPLICATION_SECRET_HEADER = 'x-application-secret';
export const handler = async (
event: SubmitPartnerApplicationEvent | SubmitPartnerApplicationInput,
): Promise<SubmitPartnerApplicationResult> => {
// Accept either { body, headers } (HTTP) or a flat input object (direct call from tests).
const looksLikeEvent =
typeof event === 'object' &&
event !== null &&
('body' in event || 'headers' in event);
const headers = looksLikeEvent
? (event as SubmitPartnerApplicationEvent).headers ?? {}
: {};
const rawInput = looksLikeEvent
? (event as SubmitPartnerApplicationEvent).body
: event;
// Shared-secret guard. The Twenty SDK's isAuthRequired flag only accepts
// user-session JWTs, not workspace API keys, so we authenticate at the
// handler level via a custom header allowlisted in forwardedRequestHeaders.
const expectedSecret = process.env.PARTNER_APPLICATION_SECRET;
if (!isNonEmptyString(expectedSecret)) {
return { ok: false, reason: 'unauthorized' };
}
const providedSecret = headers[APPLICATION_SECRET_HEADER];
if (providedSecret !== expectedSecret) {
return { ok: false, reason: 'unauthorized' };
}
const parsed = submitPartnerApplicationSchema.safeParse(rawInput);
if (!parsed.success) {
return { ok: false, reason: 'invalid_input' };
}
const input = parsed.data;
try {
const client = new CoreApiClient();
const email = input.email.trim();
const partnerFields = buildPartnerFields(input);
const personLookup = await client.query({
people: {
__args: {
filter: { emails: { primaryEmail: { eq: email } } },
first: 1,
},
edges: {
node: {
id: true,
partner: { id: true, company: { id: true } },
},
},
},
});
const existingEdge = personLookup.people?.edges?.[0]?.node;
if (existingEdge && existingEdge.partner) {
const partnerId = existingEdge.partner.id;
await client.mutation({
updatePartner: {
__args: { id: partnerId, data: partnerFields },
id: true,
},
});
await client.mutation({
updatePerson: {
__args: {
id: existingEdge.id,
data: {
name: { firstName: input.firstName.trim(), lastName: input.lastName.trim() },
},
},
id: true,
},
});
return { ok: true, created: false, partnerId };
}
const companyData: CoreSchema.CompanyCreateInput = { name: input.companyName.trim() };
if (isNonEmptyString(input.domainName)) {
companyData.domainName = { primaryLinkUrl: input.domainName.trim() };
}
const companyResult = await client.mutation({
createCompany: { __args: { data: companyData }, id: true },
});
const companyId = companyResult.createCompany?.id;
if (companyId === undefined) {
throw new Error('createCompany did not return an id');
}
const partnerResult = await client.mutation({
createPartner: {
__args: {
data: {
...partnerFields,
slug: slugify(input.companyName),
validationStage: 'APPLICATION',
reviewed: false,
partnerTier: 'NEW',
companyId,
},
},
id: true,
},
});
const partnerId = partnerResult.createPartner?.id;
if (partnerId === undefined) {
throw new Error('createPartner did not return an id');
}
if (existingEdge) {
await client.mutation({
updatePerson: {
__args: {
id: existingEdge.id,
data: {
partnerId,
name: { firstName: input.firstName.trim(), lastName: input.lastName.trim() },
},
},
id: true,
},
});
} else {
await client.mutation({
createPerson: {
__args: {
data: {
name: { firstName: input.firstName.trim(), lastName: input.lastName.trim() },
emails: { primaryEmail: email },
partnerId,
companyId,
},
},
id: true,
},
});
}
return { ok: true, created: true, partnerId };
} catch (err) {
return { ok: false, reason: err instanceof Error ? err.message : String(err) };
}
};
export default defineLogicFunction({
universalIdentifier: SUBMIT_PARTNER_APPLICATION_LOGIC_FUNCTION_ID,
name: 'submit-partner-application',
description: 'Receive a partner application from the website and idempotently upsert Partner / Person / Company.',
timeoutSeconds: 15,
handler,
httpRouteTriggerSettings: {
path: '/partner-applications',
httpMethod: 'POST',
isAuthRequired: false,
forwardedRequestHeaders: [APPLICATION_SECRET_HEADER],
},
});
@@ -9,7 +9,7 @@ export default defineNavigationMenuItem({
universalIdentifier: PARTNERS_NAV_UNIVERSAL_IDENTIFIER,
type: NavigationMenuItemType.VIEW,
icon: 'IconBuildingStore',
position: 0,
position: 2,
folderUniversalIdentifier: '857be3b5-82c6-45f7-b546-e20a8a97be8d',
viewUniversalIdentifier: ALL_PARTNERS_VIEW_UNIVERSAL_IDENTIFIER,
});
@@ -9,7 +9,7 @@ export default defineNavigationMenuItem({
universalIdentifier: VALIDATED_PARTNERS_NAV_UNIVERSAL_IDENTIFIER,
type: NavigationMenuItemType.VIEW,
icon: 'IconCircleCheck',
position: 2,
position: 0,
folderUniversalIdentifier: '857be3b5-82c6-45f7-b546-e20a8a97be8d',
viewUniversalIdentifier: VALIDATED_PARTNERS_VIEW_UNIVERSAL_IDENTIFIER,
});
@@ -84,7 +84,7 @@ export default defineObject({
universalIdentifier: '500021ad-ca42-4fd3-8727-392dd26b722a',
type: FieldType.MULTI_SELECT,
name: 'partnerScope',
label: 'Partner Scope',
label: 'Categories',
icon: 'IconListCheck',
isNullable: true,
options: [
@@ -93,6 +93,11 @@ export default defineObject({
{ id: 'c88bf189-8be4-4431-aa03-85928f8b2a52', value: 'DATA_MIGRATION', label: 'Data migration', position: 2, color: 'turquoise' },
{ id: 'a7fd9429-c26f-49ab-bf52-4d591f5ca7a0', value: 'HOSTING_ENVIRONMENT', label: 'Hosting environment', position: 3, color: 'purple' },
{ id: '9e416a39-05c6-4c80-9f36-99b5b60c26ec', value: 'WORKFLOWS', label: 'Workflows', position: 4, color: 'orange' },
{ id: 'b1000001-0000-4000-8000-0000000000a1', value: 'ADVISORY', label: 'Advisory & Discovery', position: 5, color: 'blue' },
{ id: 'b1000002-0000-4000-8000-0000000000a2', value: 'SOLUTIONING', label: 'Solutioning', position: 6, color: 'green' },
{ id: 'b1000003-0000-4000-8000-0000000000a3', value: 'DEVELOPMENT', label: 'Custom Development', position: 7, color: 'turquoise' },
{ id: 'b1000004-0000-4000-8000-0000000000a4', value: 'HOSTING', label: 'Hosting & Infrastructure', position: 8, color: 'purple' },
{ id: 'b1000005-0000-4000-8000-0000000000a5', value: 'SUPPORT', label: 'Training & Adoption', position: 9, color: 'pink' },
],
},
{
@@ -423,14 +428,6 @@ export default defineObject({
icon: 'IconCoin',
isNullable: true,
},
{
universalIdentifier: 'ced87a97-cb2a-43cb-a6fc-4a1eff2892ba',
type: FieldType.CURRENCY,
name: 'projectBudgetTypical',
label: 'Project Budget Typical',
icon: 'IconCoins',
isNullable: true,
},
{
universalIdentifier: '6a095709-7620-495f-b6e0-790743e412d5',
type: FieldType.CURRENCY,
@@ -471,6 +468,14 @@ export default defineObject({
icon: 'IconFileText',
isNullable: true,
},
{
universalIdentifier: 'a0000011-0000-4000-8000-000000000011',
type: FieldType.TEXT,
name: 'applicationNotes',
label: 'Application Notes',
icon: 'IconClipboardText',
isNullable: true,
},
{
universalIdentifier: 'a0000010-0000-4000-8000-000000000010',
type: FieldType.DATE_TIME,
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import { mapLegacyScope } from '../partner-scope-map';
describe('mapLegacyScope', () => {
it('maps each legacy value to its new category', () => {
expect(mapLegacyScope(['APPS'])).toEqual(['DEVELOPMENT']);
expect(mapLegacyScope(['DATA_MODEL'])).toEqual(['SOLUTIONING']);
expect(mapLegacyScope(['DATA_MIGRATION'])).toEqual(['SOLUTIONING']);
expect(mapLegacyScope(['WORKFLOWS'])).toEqual(['SOLUTIONING']);
expect(mapLegacyScope(['HOSTING_ENVIRONMENT'])).toEqual(['HOSTING']);
});
it('de-duplicates collapsed values', () => {
expect(mapLegacyScope(['DATA_MODEL', 'DATA_MIGRATION', 'WORKFLOWS'])).toEqual([
'SOLUTIONING',
]);
});
it('passes through already-migrated values unchanged', () => {
expect(mapLegacyScope(['ADVISORY', 'HOSTING'])).toEqual(['ADVISORY', 'HOSTING']);
});
});
@@ -27,6 +27,9 @@ config({ path: process.env.ENV_FILE ?? '.env.local' });
import { CoreApiClient } from 'twenty-client-sdk/core';
import { mapLegacyScope } from './partner-scope-map';
import { slugify } from './slugify';
const requireEnv = (name: string): string => {
const value = process.env[name];
if (!value) throw new Error(`Missing ${name} env var`);
@@ -99,13 +102,6 @@ const LOCAL_OPTIONS: Record<string, Set<string>> = {
quoteStatus: new Set(['WIP', 'INTERVIEW_SCHEDULED', 'UNDER_CUSTOMER_PARTNER_REVIEW', 'APPROVED', 'REJECTED']),
};
const slugify = (s: string): string =>
s
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
const edges = (result: any, key: string): any[] =>
(result?.[key]?.edges ?? []).map((e: any) => e.node);
@@ -164,7 +160,6 @@ async function main() {
linkedinLink { primaryLinkUrl }
partnerStage partnerTier partnerScope partnerTypeOfTeam partnerTimezone partnerIsAvailable partnerSkills
partnerBudgetMinimum { amountMicros currencyCode }
partnerBudgetAverage { amountMicros currencyCode }
company { id name domainName { primaryLinkUrl } }
} }
}
@@ -378,8 +373,11 @@ async function main() {
// Timezone band -> geographic region(s). Unmapped/OTHER -> no region.
const region = TIMEZONE_TO_REGION[p.partnerTimezone] ?? [];
// A partner scoped for hosting is, by definition, a self-host expert.
const scope = Array.isArray(p.partnerScope) ? p.partnerScope : [];
const deploymentExpertise = scope.includes('HOSTING_ENVIRONMENT') ? ['SELF_HOST'] : [];
const rawScope = Array.isArray(p.partnerScope) ? p.partnerScope : [];
// Map legacy TFT categories to the validated set so the import never
// re-introduces retired values.
const scope = mapLegacyScope(rawScope);
const deploymentExpertise = rawScope.includes('HOSTING_ENVIRONMENT') ? ['SELF_HOST'] : [];
const data: Record<string, unknown> = {
name: [p.name?.firstName, p.name?.lastName].filter(Boolean).join(' ').trim() || 'Unknown partner',
slug,
@@ -395,7 +393,6 @@ async function main() {
...(Array.isArray(p.partnerSkills) && p.partnerSkills.length ? { skills: p.partnerSkills } : {}),
...(p.city ? { city: p.city } : {}),
...(budgetCurrency(p.partnerBudgetMinimum) ? { projectBudgetMin: budgetCurrency(p.partnerBudgetMinimum) } : {}),
...(budgetCurrency(p.partnerBudgetAverage) ? { projectBudgetTypical: budgetCurrency(p.partnerBudgetAverage) } : {}),
...(p.linkedinLink?.primaryLinkUrl ? { linkedin: { primaryLinkUrl: p.linkedinLink.primaryLinkUrl } } : {}),
...(companyId && APPLY ? { companyId } : {}),
};
@@ -0,0 +1,79 @@
// Remap legacy partnerScope values to the validated categories.
//
// yarn migrate:partner-scope # dry-run against .env.local
// MIGRATE_APPLY=1 yarn migrate:partner-scope
// yarn migrate:partner-scope:prod # dry-run against .env.prod
//
// Adding the new options is done in partner.object.ts (additive). This script
// rewrites existing records old→new so the old options can later be removed.
import { config } from 'dotenv';
config({ path: process.env.ENV_FILE ?? '.env.local' });
import { CoreApiClient } from 'twenty-client-sdk/core';
import { fileURLToPath } from 'url';
import { mapLegacyScope } from './partner-scope-map';
const requireEnv = (name: string): string => {
const value = process.env[name];
if (!value) throw new Error(`Missing ${name} env var`);
return value;
};
async function main() {
const apply = process.env.MIGRATE_APPLY === '1';
const url = `${requireEnv('TWENTY_PARTNERS_API_URL').replace(/\/$/, '')}/graphql`;
const client = new CoreApiClient({
url,
headers: { Authorization: `Bearer ${requireEnv('TWENTY_PARTNERS_API_KEY')}` },
});
console.log(`[migrate-scope] target: ${url}${apply ? 'APPLY' : 'dry-run'}`);
// Pass 1: page through ALL partners read-only and collect those that need remapping.
type Pending = { id: string; current: string[]; next: string[] };
const pending: Pending[] = [];
let after: string | null = null;
// eslint-disable-next-line no-constant-condition
while (true) {
const data: any = await client.query({
partners: {
__args: after ? { first: 50, after } : { first: 50 },
edges: { node: { id: true, partnerScope: true }, cursor: true },
pageInfo: { hasNextPage: true, endCursor: true },
},
} as any);
const edges = data.partners?.edges ?? [];
for (const edge of edges) {
const id = edge.node.id as string;
const current = (edge.node.partnerScope ?? []) as string[];
const next = mapLegacyScope(current);
const isChanged =
next.length !== current.length || next.some((v, i) => v !== current[i]);
if (!isChanged) continue;
pending.push({ id, current, next });
console.log(`[migrate-scope] ${id}: ${JSON.stringify(current)} -> ${JSON.stringify(next)}`);
}
if (!data.partners?.pageInfo?.hasNextPage) break;
after = data.partners.pageInfo.endCursor;
}
// Pass 2: apply the remapping mutations (only when MIGRATE_APPLY=1).
if (apply) {
for (const { id, next } of pending) {
await client.mutation({
updatePartner: { __args: { id, data: { partnerScope: next } }, id: true },
} as any);
}
}
console.log(`[migrate-scope] ${apply ? 'updated' : 'would update'} ${pending.length} partner(s)`);
if (!apply) console.log('[migrate-scope] re-run with MIGRATE_APPLY=1 to apply');
}
// Only run when invoked directly (so unit tests can import mapLegacyScope safely).
if (process.argv[1] === fileURLToPath(import.meta.url)) {
main().catch((err) => {
console.error(err);
process.exit(1);
});
}
@@ -0,0 +1,19 @@
// Legacy -> validated partnerScope category mapping. Single source of truth,
// shared by the migration script (rewrites existing records) and the TFT import
// (so it never writes retired legacy values back in).
export const LEGACY_SCOPE_MAP: Record<string, string> = {
APPS: 'DEVELOPMENT',
DATA_MODEL: 'SOLUTIONING',
DATA_MIGRATION: 'SOLUTIONING',
WORKFLOWS: 'SOLUTIONING',
HOSTING_ENVIRONMENT: 'HOSTING',
};
export function mapLegacyScope(values: ReadonlyArray<string>): string[] {
const out: string[] = [];
for (const value of values) {
const mapped = LEGACY_SCOPE_MAP[value] ?? value;
if (!out.includes(mapped)) out.push(mapped);
}
return out;
}
@@ -22,6 +22,14 @@ const requireEnv = (name: string): string => {
};
const CAL = 'https://calendly.com/placeholder';
const usd = (dollars: number) => ({
amountMicros: dollars * 1_000_000,
currencyCode: 'USD',
});
// Deterministic placeholder avatars; pravatar returns a stable image per `u` seed.
const avatar = (slug: string) => `https://i.pravatar.cc/300?u=${slug}`;
const linkedin = (slug: string) =>
`https://www.linkedin.com/company/${slug}`;
type Partner = {
slug: string;
@@ -37,19 +45,23 @@ type Partner = {
partnerScope: string[];
typeOfTeam: string;
country: string;
city: string;
hourlyRateUsd: number | null;
projectBudgetMinUsd: number | null;
skills: string[];
};
const PARTNERS: Partner[] = [
{ slug: 'nine-dots-ventures', name: 'Nine Dots Ventures', validationStage: 'VALIDATED', availability: 'AVAILABLE', introduction: 'Boutique CRM implementer for real-estate workflows and WhatsApp automation.', calendarLink: CAL, deploymentExpertise: ['CLOUD', 'SELF_HOST'], region: ['EUROPE', 'MENA'], languagesSpoken: ['ENGLISH', 'FRENCH', 'ARABIC'], partnerTier: 'ADVANCED', partnerScope: ['DATA_MODEL', 'WORKFLOWS', 'APPS'], typeOfTeam: 'AGENCY', country: 'FRANCE' },
{ slug: 'elevate-consulting', name: 'Elevate Consulting', validationStage: 'VALIDATED', availability: 'AVAILABLE', introduction: 'Revenue-operations partner for B2B SaaS teams scaling seed to Series C.', calendarLink: CAL, deploymentExpertise: ['CLOUD'], region: ['US', 'LATAM'], languagesSpoken: ['ENGLISH', 'SPANISH'], partnerTier: 'INTERMEDIATE', partnerScope: ['DATA_MIGRATION', 'DATA_MODEL'], typeOfTeam: 'AGENCY', country: 'UNITED_STATES' },
{ slug: 'w3villa-technologies', name: 'W3Villa Technologies', validationStage: 'VALIDATED', availability: 'AVAILABLE', introduction: 'Engineering-heavy partner running large self-hosted Twenty deployments.', calendarLink: CAL, deploymentExpertise: ['CLOUD', 'SELF_HOST'], region: ['APAC', 'MENA'], languagesSpoken: ['ENGLISH', 'HINDI'], partnerTier: 'ADVANCED', partnerScope: ['HOSTING_ENVIRONMENT', 'APPS', 'WORKFLOWS'], typeOfTeam: 'AGENCY', country: 'INDIA' },
{ slug: 'act-education', name: 'Act Education', validationStage: 'VALIDATED', availability: 'UNAVAILABLE', introduction: 'CRM partner for European education providers; compliance-first self-hosting.', calendarLink: CAL, deploymentExpertise: ['SELF_HOST'], region: ['EUROPE'], languagesSpoken: ['ENGLISH', 'GERMAN'], partnerTier: 'NEW', partnerScope: ['HOSTING_ENVIRONMENT', 'DATA_MODEL'], typeOfTeam: 'SOLO', country: 'GERMANY' },
{ slug: 'netzero-systems', name: 'NetZero Systems', validationStage: 'VALIDATED', availability: 'AVAILABLE', introduction: 'LATAM go-to-market partner for climate-tech and renewable-energy companies.', calendarLink: CAL, deploymentExpertise: ['CLOUD'], region: ['LATAM', 'US'], languagesSpoken: ['ENGLISH', 'SPANISH', 'PORTUGUESE'], partnerTier: 'INTERMEDIATE', partnerScope: ['DATA_MODEL'], typeOfTeam: 'AGENCY', country: 'BRAZIL' },
{ slug: 'meridian-craft', name: 'Meridian Craft', validationStage: 'VALIDATED', availability: 'AVAILABLE', introduction: 'APAC implementation studio for fintech and logistics; English + Chinese.', calendarLink: CAL, deploymentExpertise: ['CLOUD', 'SELF_HOST'], region: ['APAC', 'AFRICA'], languagesSpoken: ['ENGLISH', 'CHINESE', 'MALAY'], partnerTier: 'ADVANCED', partnerScope: ['APPS', 'WORKFLOWS'], typeOfTeam: 'AGENCY', country: 'SINGAPORE' },
{ slug: 'applicant-studio', name: 'Applicant Studio', validationStage: 'APPLICATION', availability: 'UNAVAILABLE', introduction: 'New applicant; awaiting first review.', calendarLink: CAL, deploymentExpertise: ['CLOUD'], region: ['EUROPE'], languagesSpoken: ['ENGLISH', 'FRENCH'], partnerTier: 'NEW', partnerScope: ['DATA_MODEL'], typeOfTeam: 'SOLO', country: 'FRANCE' },
{ slug: 'rising-crm', name: 'Rising CRM', validationStage: 'POTENTIAL', availability: 'AVAILABLE', introduction: 'Promising applicant in evaluation.', calendarLink: CAL, deploymentExpertise: ['CLOUD', 'SELF_HOST'], region: ['US'], languagesSpoken: ['ENGLISH'], partnerTier: 'NEW', partnerScope: ['WORKFLOWS', 'APPS'], typeOfTeam: 'AGENCY', country: 'UNITED_STATES' },
{ slug: 'legacy-partners', name: 'Legacy Partners', validationStage: 'FORMER', availability: 'UNAVAILABLE', introduction: 'Former partner; no longer active in the program.', calendarLink: CAL, deploymentExpertise: ['SELF_HOST'], region: ['EUROPE'], languagesSpoken: ['ENGLISH', 'GERMAN'], partnerTier: 'INTERMEDIATE', partnerScope: ['HOSTING_ENVIRONMENT'], typeOfTeam: 'AGENCY', country: 'UNITED_KINGDOM' },
{ slug: 'declined-co', name: 'Declined Co', validationStage: 'REJECTED', availability: 'UNAVAILABLE', introduction: 'Application rejected after review.', calendarLink: CAL, deploymentExpertise: ['CLOUD'], region: ['MENA'], languagesSpoken: ['ENGLISH', 'ARABIC'], partnerTier: 'NEW', partnerScope: ['APPS'], typeOfTeam: 'SOLO', country: 'UNITED_ARAB_EMIRATES' },
{ slug: 'nine-dots-ventures', name: 'Nine Dots Ventures', validationStage: 'VALIDATED', availability: 'AVAILABLE', introduction: 'Boutique CRM implementer for real-estate workflows and WhatsApp automation. Nine Dots runs end-to-end Twenty rollouts for property managers and brokerages across Europe and MENA, with deep multi-language data models and AI-assisted lead intake.', calendarLink: CAL, deploymentExpertise: ['CLOUD', 'SELF_HOST'], region: ['EUROPE', 'MENA'], languagesSpoken: ['ENGLISH', 'FRENCH', 'ARABIC'], partnerTier: 'ADVANCED', partnerScope: ['DATA_MODEL', 'WORKFLOWS', 'APPS'], typeOfTeam: 'AGENCY', country: 'FRANCE', city: 'Paris', hourlyRateUsd: 250, projectBudgetMinUsd: 15000, skills: ['Real estate', 'WhatsApp', 'Multi-language', 'Workflows', 'Integrations', 'AI'] },
{ slug: 'elevate-consulting', name: 'Elevate Consulting', validationStage: 'VALIDATED', availability: 'AVAILABLE', introduction: 'Revenue-operations partner for B2B SaaS teams scaling seed to Series C. Elevate moves teams off legacy CRMs onto Twenty with a four-week migration playbook, pipeline rebuilds, and analytics handoff.', calendarLink: CAL, deploymentExpertise: ['CLOUD'], region: ['US', 'LATAM'], languagesSpoken: ['ENGLISH', 'SPANISH'], partnerTier: 'INTERMEDIATE', partnerScope: ['DATA_MIGRATION', 'DATA_MODEL'], typeOfTeam: 'AGENCY', country: 'UNITED_STATES', city: 'Austin', hourlyRateUsd: 200, projectBudgetMinUsd: 20000, skills: ['RevOps', 'B2B SaaS', 'Data migration', 'Pipelines', 'Salesforce migration', 'HubSpot migration'] },
{ slug: 'w3villa-technologies', name: 'W3Villa Technologies', validationStage: 'VALIDATED', availability: 'AVAILABLE', introduction: 'Engineering-heavy partner running large self-hosted Twenty deployments. Specializes in hardened Kubernetes hosting, custom integrations, and 24/7 support contracts for regulated industries across APAC and the Gulf.', calendarLink: CAL, deploymentExpertise: ['CLOUD', 'SELF_HOST'], region: ['APAC', 'MENA'], languagesSpoken: ['ENGLISH', 'HINDI'], partnerTier: 'ADVANCED', partnerScope: ['HOSTING_ENVIRONMENT', 'APPS', 'WORKFLOWS'], typeOfTeam: 'AGENCY', country: 'INDIA', city: 'Bangalore', hourlyRateUsd: 120, projectBudgetMinUsd: 10000, skills: ['Self-hosting', 'Kubernetes', 'DevOps', 'Integrations', 'Workflows', 'Enterprise support'] },
{ slug: 'act-education', name: 'Act Education', validationStage: 'VALIDATED', availability: 'UNAVAILABLE', introduction: 'CRM partner for European education providers; compliance-first self-hosting on EU infrastructure with full GDPR data residency and student-record workflows.', calendarLink: CAL, deploymentExpertise: ['SELF_HOST'], region: ['EUROPE'], languagesSpoken: ['ENGLISH', 'GERMAN'], partnerTier: 'NEW', partnerScope: ['HOSTING_ENVIRONMENT', 'DATA_MODEL'], typeOfTeam: 'SOLO', country: 'GERMANY', city: 'Berlin', hourlyRateUsd: 180, projectBudgetMinUsd: 8000, skills: ['Education', 'Compliance', 'Self-hosting', 'GDPR', 'Data privacy'] },
{ slug: 'netzero-systems', name: 'NetZero Systems', validationStage: 'VALIDATED', availability: 'AVAILABLE', introduction: 'LATAM go-to-market partner for climate-tech and renewable-energy companies. Builds bilingual sales pipelines, ESG reporting, and grant-management workflows on top of Twenty.', calendarLink: CAL, deploymentExpertise: ['CLOUD'], region: ['LATAM', 'US'], languagesSpoken: ['ENGLISH', 'SPANISH', 'PORTUGUESE'], partnerTier: 'INTERMEDIATE', partnerScope: ['DATA_MODEL'], typeOfTeam: 'AGENCY', country: 'BRAZIL', city: 'São Paulo', hourlyRateUsd: 150, projectBudgetMinUsd: 12000, skills: ['Climate tech', 'Renewable energy', 'ESG reporting', 'Bilingual pipelines', 'LATAM go-to-market'] },
{ slug: 'meridian-craft', name: 'Meridian Craft', validationStage: 'VALIDATED', availability: 'AVAILABLE', introduction: 'APAC implementation studio for fintech and logistics. Senior team of ex-bank engineers building high-throughput Twenty deployments across Singapore, Hong Kong, and Kuala Lumpur.', calendarLink: CAL, deploymentExpertise: ['CLOUD', 'SELF_HOST'], region: ['APAC', 'AFRICA'], languagesSpoken: ['ENGLISH', 'CHINESE', 'MALAY'], partnerTier: 'ADVANCED', partnerScope: ['APPS', 'WORKFLOWS'], typeOfTeam: 'AGENCY', country: 'SINGAPORE', city: 'Singapore', hourlyRateUsd: 300, projectBudgetMinUsd: 25000, skills: ['Fintech', 'Logistics', 'APAC', 'High throughput', 'Custom apps', 'Performance tuning'] },
{ slug: 'applicant-studio', name: 'Applicant Studio', validationStage: 'APPLICATION', availability: 'UNAVAILABLE', introduction: 'New applicant; awaiting first review.', calendarLink: CAL, deploymentExpertise: ['CLOUD'], region: ['EUROPE'], languagesSpoken: ['ENGLISH', 'FRENCH'], partnerTier: 'NEW', partnerScope: ['DATA_MODEL'], typeOfTeam: 'SOLO', country: 'FRANCE', city: 'Lyon', hourlyRateUsd: null, projectBudgetMinUsd: null, skills: ['Boutique', 'Design'] },
{ slug: 'rising-crm', name: 'Rising CRM', validationStage: 'POTENTIAL', availability: 'AVAILABLE', introduction: 'Promising applicant in evaluation.', calendarLink: CAL, deploymentExpertise: ['CLOUD', 'SELF_HOST'], region: ['US'], languagesSpoken: ['ENGLISH'], partnerTier: 'NEW', partnerScope: ['WORKFLOWS', 'APPS'], typeOfTeam: 'AGENCY', country: 'UNITED_STATES', city: 'New York', hourlyRateUsd: null, projectBudgetMinUsd: null, skills: ['SMB', 'Quick setup'] },
{ slug: 'legacy-partners', name: 'Legacy Partners', validationStage: 'FORMER', availability: 'UNAVAILABLE', introduction: 'Former partner; no longer active in the program.', calendarLink: CAL, deploymentExpertise: ['SELF_HOST'], region: ['EUROPE'], languagesSpoken: ['ENGLISH', 'GERMAN'], partnerTier: 'INTERMEDIATE', partnerScope: ['HOSTING_ENVIRONMENT'], typeOfTeam: 'AGENCY', country: 'UNITED_KINGDOM', city: 'London', hourlyRateUsd: null, projectBudgetMinUsd: null, skills: ['Enterprise', 'Self-hosting'] },
{ slug: 'declined-co', name: 'Declined Co', validationStage: 'REJECTED', availability: 'UNAVAILABLE', introduction: 'Application rejected after review.', calendarLink: CAL, deploymentExpertise: ['CLOUD'], region: ['MENA'], languagesSpoken: ['ENGLISH', 'ARABIC'], partnerTier: 'NEW', partnerScope: ['APPS'], typeOfTeam: 'SOLO', country: 'UNITED_ARAB_EMIRATES', city: 'Dubai', hourlyRateUsd: null, projectBudgetMinUsd: null, skills: ['MENA', 'Arabic'] },
];
const COMPANIES = [
@@ -118,7 +130,12 @@ async function main() {
introduction: p.introduction, calendarLink: { primaryLinkUrl: p.calendarLink },
deploymentExpertise: p.deploymentExpertise, region: p.region, languagesSpoken: p.languagesSpoken,
partnerTier: p.partnerTier, partnerScope: p.partnerScope, typeOfTeam: p.typeOfTeam,
country: p.country,
country: p.country, city: p.city,
skills: p.skills,
linkedin: { primaryLinkUrl: linkedin(p.slug) },
profilePicture: { primaryLinkUrl: avatar(p.slug) },
...(p.hourlyRateUsd != null ? { hourlyRate: usd(p.hourlyRateUsd) } : {}),
...(p.projectBudgetMinUsd != null ? { projectBudgetMin: usd(p.projectBudgetMinUsd) } : {}),
};
const id = partnerIdBySlug.get(p.slug);
if (id) {
@@ -0,0 +1,11 @@
// Shared slug helper. Algorithm is intentionally kept simple (no NFKD
// normalization) so that slugs produced by the import script and by the
// partner-application handler are byte-for-byte identical. The import uses
// slug as an idempotency/upsert key (partnerIdBySlug), so the algorithm must
// never change for existing data.
export const slugify = (s: string): string =>
s
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
@@ -0,0 +1,116 @@
---
name: twenty-lead-intro-call-summary
description: Turn a sales/discovery-call transcript into a faithful, structured qualification brief for Twenty's partner/CRM pipeline. Use this whenever the user has a call recording or transcript — including a meetily recordings folder or a transcripts.json — and wants to summarize, recap, qualify, or extract a brief from a prospect/discovery call. Trigger even when they don't say "brief": phrases like "summarize this call", "what did we learn from the X call", "qualify this lead from the call", "turn this transcript into something I can hand a partner", or pointing at a transcript file all count. Produces a tight deal one-pager (company, needs, implementation complexity, does-it-need-a-partner, partner-facing brief) plus an appendix (product/GTM feedback, terminology, quotes). It is a faithful extraction, not a lossy summary.
trigger: /twenty-lead-intro-call-summary
---
# twenty-lead-intro-call-summary
Turn a discovery/sales-call transcript into a **qualification brief** that (a) loses no
decision-relevant detail and (b) can be used to qualify the deal and hand it to an
implementation partner. Designed for Twenty's partner/CRM pipeline, tuned for messy,
label-less ASR transcripts (e.g. meetily exports).
The output is two parts: a tight **Part A deal one-pager** (everything the deal/partner
work needs) and a **Part B appendix** (not deal-specific: product feedback, terminology,
quotes). The structure is what prevents loss — a "summary that loses nothing" is a
contradiction, so this is a *structured extraction* against a fixed schema where every
dimension has a slot and gaps are marked rather than silently dropped.
## Step 1 — Get the transcript
The transcript is provided by the user — usually pasted text, or a path they point you at
(a `.txt`/`.vtt`/`.srt`, or a meetily recording folder / `transcripts.json`). If they give a
path, read it: a meetily `transcripts.json` is `{ "segments": [ { "text": ... } ] }`
concatenate the `text` values in order; for `.vtt`/`.srt`, drop the cue numbers and
timecodes.
**If no transcript is provided, ask for it before doing anything else.** Don't fabricate or
proceed without one.
For very long transcripts, read the whole thing before writing — coverage is the point.
## Step 2 — Produce the brief
Follow these rules and fill this exact schema. Why each rule matters is noted, because
faithful extraction depends on judgment, not rote form-filling.
**Rules**
- Extract ONLY what is in the transcript. Never invent or assume. If a field isn't covered,
write "Not discussed." (Visible gaps beat confident fabrication — a missing field is a
signal to ask on the next call.)
- Separate stated FACTS from your INFERENCES; mark any inference "(inferred)". (Downstream
matching trusts the facts; don't contaminate them with guesses.)
- Preserve specifics verbatim: numbers, dates, names + roles, tool/CRM names, prices,
budgets, exact requirements. Don't round or paraphrase numbers. (Specifics are where
nuance and matching signal live; paraphrase kills them.)
- Use the customer's own words for needs and objections; quote pivotal lines.
- Don't smooth over contradictions or vagueness — note them. (A flagged contradiction is
more useful than a falsely tidy summary.)
- If the transcript has NO speaker labels, infer from context who is the vendor (Twenty)
and who is the prospect, and attribute accordingly. Names get garbled by speech-to-text —
flag any uncertain name with "(uncertain)" and never invent a name.
- Keep PART A tight — it's the one-pager the deal/partner work runs on. Push everything not
specific to this deal (product/website feedback, terminology, supporting quotes) to
PART B. State each fact once; never repeat it across sections.
**Output (use these exact headers)**
```
== PART A — DEAL ONE-PAGER ==
1. ONE-LINE SUMMARY
2. COMPANY — name, what they do, size/employees, HQ + countries of operation, industry
3. PEOPLE ON THE CALL — name, role/title, side (Twenty vs prospect); infer roles if
unlabeled and flag uncertain names
4. CURRENT SITUATION — what CRM/tools they use today; specific pains
5. WHY THEY'RE INTERESTED IN TWENTY
6. WHAT THEY WANT — bulleted needs/requirements, verbatim where possible
7. IMPLEMENTATION COMPLEXITY (for partner matching)
- Deployment: cloud / self-host / both / unclear (+ the evidence)
- Data model: custom objects, multi-tenant, row-level security, migrations
- Integrations / custom apps needed
- Workflows / automation needs
- Scale: number of seats/users
- Region + language the partner would need to cover
8. COMMERCIALS — budget or prices discussed, plan tier (Pro/Org/Enterprise), seat count,
deal value, who pays
9. TIMELINE & DECISION — key dates, decision-makers, urgency, decision process
10. OBJECTIONS / RISKS / FEARS — including anything that could kill the deal
11. ALTERNATIVES — competitors or other options they're weighing
12. DOES THIS DEAL NEED A PARTNER? — yes / no / maybe + why; and if yes, what kind
(scope, region, language, seniority/tier)
13. NEXT STEPS / OPEN QUESTIONS / FOLLOW-UPS
14. PARTNER-FACING BRIEF — a 2-4 sentence narrative a partner can skim to decide yes/no,
drawn only from PART A
== PART B — APPENDIX (not deal-specific) ==
15. PRODUCT / WEBSITE / GTM FEEDBACK — any feedback on the product, pricing page, website
wording, onboarding, or trial; capture even if off-topic for qualification
16. TERMINOLOGY / DOMAIN-LANGUAGE NOTES — words used on the call that mean different things
to each side or carry domain-specific meaning (e.g. "partner", "donor", jargon)
17. KEY VERBATIM QUOTES — 3-8 direct quotes that capture intent, needs, or objections
```
## Step 3 — Output and save
Print the brief in the conversation. Then offer to save it as Markdown — default to a
sibling of the source (e.g. next to the recording) or, for Twenty work, the
`partners-experience/research/` folder, named `YYYY-MM-DD-<company>-call-summary.md`. Don't
write the file unless the user wants it saved.
## How the fields map to the Partner model (for matching)
Section 7 + 12 are the matching axes: `Deployment → deploymentExpertise`, the scope needs →
`partnerScope`, `Region + language` → partner region/languages, scale → capacity, the
"needs a partner?" tier → `partnerTier`. Keeping these explicit is what lets the brief drop
straight into the opportunity→partner handover flow.
## Notes
- This is tuned for Twenty discovery calls, but the schema generalizes — swap the vendor
framing in section 5/12 if reused elsewhere.
- The worked reference example lives at
`partners-experience/research/2026-05-21-tsf-call-summary-final.md` (and the prompt alone
at `partners-experience/research/call-summary-prompt.md`).
@@ -0,0 +1,36 @@
---
name: twenty-partner-design-doc
description: Use when turning a qualified Twenty lead — a call-summary brief plus any client braindump/docs — into an implementation design doc a partner can scope and quote from. Trigger when pointed at a lead folder (e.g. partners-experience/<LEAD>/) and asked to "draft a design doc", "translate this into Twenty terms", "scope this for a partner", or "prep the partner handoff" for a discovery-qualified prospect. Chains after twenty-lead-intro-call-summary.
trigger: /twenty-partner-design-doc
---
# twenty-partner-design-doc
Turn a qualified lead's materials into a **design doc**: a translation of the customer's needs into **Twenty terms** that an implementation **partner reads to scope and quote** the work.
**The doctrine — what to produce, the 12-section structure, the rules, the verification process, and the common mistakes — lives in `design-doc-doctrine.md` in this folder. Read it and follow it.** This file is only the Claude Code wrapper: how to gather inputs, which tools to use for each step, and where to save.
## Inputs
- A lead folder (e.g. `partners-experience/<LEAD>/`) containing a `twenty-lead-intro-call-summary` output plus any braindump / docs / notes.
- If there is a raw transcript but no brief, run **twenty-lead-intro-call-summary** first — this skill chains after it.
- Read everything. Convert `.docx` with `textutil -convert txt "<file>" -output /tmp/out.txt` (macOS) or an equivalent extractor.
## Steps
1. **Gather** — read all source materials in full. Coverage is the point.
2. **Extract needs, grounded** — facts vs inferences (`(inf.)`); never invent. Per the doctrine.
3. **Draft** the doc in the doctrine's 12-section structure, applying every rule in the doctrine.
4. **Verify load-bearing claims live** — use **WebFetch** against the Twenty doc map in the doctrine's Verification section before asserting any capability. Build the §11 appendix as you go.
5. **Reconcile discrepancies** — sources that disagree (call vs braindump; a name differing across/within sources) get flagged both ways, never silently resolved.
6. **Resolve ❓ with the operator** — after a full v1 draft, use **AskUserQuestion** to ask the Twenty team member the unknowns a Twenty insider can answer; leave customer-facing unknowns as ❓. **If running autonomously** (no operator — a subagent/batch run), skip the questions and leave every unknown as ❓ in the body and §11.
7. **Self-check, then save** — scan the output for: an em dash; a bare `~`; first-person voice outside customer quotes; local file paths; a header that isn't the four-field table; **any flag that isn't one of the four canonical emoji-and-text pairs** (`🔮 inf.`, `**❓ open**`, `**⚠️ heavy**`, `**🛑 blocker**`) — a stray 🟥, 🚩, 🚨, ✅, or a naked emoji without its text label is wrong; **a Data-model table missing the `Source` column** (`client` / `inf.`); **a section that just says "X was not named" / "no automations named" / a "left out on purpose" list** — cut it, unknowns go in Open questions; **any bare `§N` reference** instead of a functional anchor link `[§N](#n-section-slug)`; **renumbering gaps** (e.g. cut a section but kept the old numbers around it); **a section that is mostly paragraphs where bullets or a table would do** — exception: Open questions stays a numbered list; **build / runtime / SDK mechanics that don't change the quote** (Docker version, OAuth flavour, auto-system relations, env-var names, CI/CD workflow detail); a point repeated across sections instead of a `[§N](...)` cross-reference; a leftover glossary / domain-language section; any capability claim stated as fact without a References source. Fix, then save to the lead folder as `YYYY-MM-DD-<lead>-design-doc.md`.
## Worked example
Reference: `partners-experience/TSF/2026-05-26-tsf-design-doc.md` shows the target **coverage, flag discipline, and §11 verification appendix**. It predates the current concision / table-header / no-glossary / no-em-dash rules, so where its formatting differs, **follow this doctrine over the example.**
## Notes
- Chain: **twenty-lead-intro-call-summary → twenty-partner-design-doc**; the output feeds the opportunity→partner handover (`designDocStatus` / `designDocUrl`).
- **Phase 2:** `design-doc-doctrine.md` is written to be portable. A future `defineSkill` in this app (a sibling `*.skill.ts` in `src/skills/`) would import it as its `content`, driving an in-product agent — with a verify logic-function tool replacing the WebFetch step, and a Workflow Action triggering it when sales toggles `partnerEligible`. Keep doctrine changes in that file so both the Claude Code skill and the `defineSkill` stay in sync.
@@ -0,0 +1,164 @@
# Design-doc doctrine: translating a lead into Twenty terms
**Portable doctrine.** This file is tool-agnostic. It defines *what* a Twenty partner design doc is, its structure, the rules, and how to verify it. Two consumers use it: the Claude Code skill `twenty-partner-design-doc` (see `SKILL.md` in this folder), and, in a later phase, the `content` of a Twenty `defineSkill` driving an in-product agent. Keep it free of any one tool's mechanics (no file paths, no tool names).
## Purpose
Produce a **design doc** that translates a qualified lead's needs into **Twenty terms** (data model, permissions, integrations, hosting, plus whatever else the client named) so an implementation **partner can scope and quote** the work.
**Core principle:** identify *all* the work with **no blindspots**, **ground every claim** in the source or in live Twenty docs, and **present options rather than prescribe**. The partner quotes off this doc, so a confident-but-wrong capability claim or a hidden requirement is the worst failure.
**Stay on the client's outcome.** Every line must change what the partner *builds* or what the client *receives*. The doc is a design, not a meeting record: a requirement's backstory (why the vendor or a third party does or doesn't satisfy it) is not a build input. Capture the **consequence**, cut the backstory.
The doc is **partner-facing** and may be forwarded verbatim. It is a *suggestion to make scoping easier*, not a spec that constrains how the partner builds.
## Output structure
**Header: a compact table, not a stack of bold lines.** Four fields only:
| Field | Value |
|---|---|
| Lead | name (one-line description of who they are) |
| Date | YYYY-MM-DD |
| Author | Twenty (partnerships) |
| Status | one short line (e.g. "Draft for partner review") |
Keep the header to those four fields. The Status line stays short: a load-bearing caveat (e.g. "data model is an inference pending discovery") goes in the "What this is" callout, not in the header cell. Do not list source materials, internal timelines, or who-promised-what: not load-bearing, not customer-safe (the doc is forwarded verbatim).
After the table, one **"What this is"** callout framing the doc as a partner-scoping suggestion across the full surface, *not* an MVP.
**Flag legend (emoji + short text label, used together so they're scannable and unambiguous):**
- `🔮 inf.` modelling inference (yours; the partner should confirm with the client). Used inline, often, unbolded.
- **❓ open** open question to resolve before quoting.
- **⚠️ heavy** product-constrained or needs special design / has a real cost.
- **🛑 blocker** dealbreaker-grade. Doesn't quote without resolution.
Use these four pairings only. Don't invent new flags, swap the emoji (🟥 / 🚩 / 🚨 / ✅), or drop the text label and use the emoji alone.
### Required sections (always present)
Number sequentially in the final doc, with no gaps:
- **Context**: the 30-second read: who they are, what they want, deployment requirement, scale, language/region.
- **Data model in Twenty terms**: the core. Present objects as a **table, one row per object**: `Object | Std/Custom | Source | Represents | Key fields | Core relations`. The **Source** column is `client` (object/concept grounded in client speech) or `inf.` (you coined it). Inline, tag inferred field names and relations `🔮 inf.`. Spell out SELECT option sets. **Model the relationships, not just the fields**: who introduced/sourced a record (e.g. an ambassador to Opportunity `sourcedBy` link), parent/child, ownership carry as much scoping signal as the attributes. State product constraints **only when they change the build or quote**, and inline (no formula fields → reporting ratios need a logic-function-maintained stored field; custom objects auto-get attachments/notes/tasks/timeline → relationship tracking is free). Where a customer term collides with a Twenty term (their "partner" = a donor), note the mapping inline as a small "term collisions" bullet list above the table; do **not** add a glossary section for it.
- **Roles, permissions & RLS**: map named roles to Twenty's object / field / row-level model; answer "do we need RLS?" against verified, plan-gated capability.
- **Hosting & compliance**: cloud vs self-host, data-residency requirement (verify), GDPR. Flag contradictions.
- **Suggested phasing**: "(the partner's call, not Twenty's)" layers, labelled a suggestion.
- **Open questions / blindspot-killers**: the list a partner must resolve before pricing. **This is the one explicit exception to "tables over bullets": always render as a numbered list**, so the partner can speak them as items 1, 2, 3 with the client. Each item names the decision the question gates and links back to the body section with a functional cross-ref.
- **References & verification**: a table mapping each load-bearing claim to its source doc, plus an explicit list of what the docs could NOT confirm (the unresolved **❓ open** items).
### Conditional sections (include only when grounded in client speech)
If the client didn't name the topic, **omit the section entirely**. Do not include a section just to say "X was not named" — that's filler. Unknowns belong in Open questions, not in their own section.
- **Views & navigation**: include when the data model has enough scope to warrant a surface conversation. Render as a **tight table** with columns `Surface | Shows | Audience`, one row per surface (an object or a filtered subset) the workspace should expose day-to-day. **Do not** add a `Type` column (table / kanban / page layout): the view type is the partner's call, not a scoping decision. If the client explicitly named pipelines or layouts, capture them in the `Shows` cell. Keep the table to the surfaces that genuinely matter; supplement with a one-line follow-up bullet only when an open question about a cross-cutting surface needs flagging (e.g. an unscoped admin view).
- **Automations**: only if the client named processes to automate. Give Workflow-or-logic-function for each; name the automation and its trigger.
- **Integrations**: only when the client explicitly names an external system to connect (Gmail, Slack, WhatsApp, etc.). Direction, indicative mechanism (not prescriptive), data flow, risks. If you spot a likely integration that wasn't named, raise it as a single **❓ open** in Open questions, not as a whole section.
- **Reporting & analytics**: only if the client named reporting needs. Map them to native Dashboards; flag gaps with paths.
Number whatever you include sequentially. A doc with Context, Data model, Integrations (Gmail named), Permissions, Hosting, Phasing, Open questions, References is §1 through §8. Don't leave gaps.
Scale each section to its content. **Coverage of surface area matters more than depth per item.**
## Rules (and why each matters)
- **Be concise: maximum signal per word.** Say a lot in few words. Cut throat-clearing, scene-setting, hedges, and feature-tour prose; prefer a table or a tight clause to a paragraph. Length is not coverage: a short doc that names every requirement beats a long one that pads each. A hesitant buyer reads a focused doc; a bloated one reads as cost.
- **Bullets and tables over paragraphs, with one exception.** Default to bullets; reach for a table whenever rows share structure (objects, views, plans, paths). Use prose only for a nuance no bullet or table cell can carry. The Open questions section is the deliberate exception: always a numbered list, so the partner can read item 1, 2, 3 aloud.
- **Business decisions over technical mechanics.** Scope is what the partner *builds* and what the client *receives*: data sensitivity, who-sees-what, plan choice, hosting choice, integration surface, cost drivers. Cut SDK / runtime / build-tool internals that don't change the quote (Docker version, OAuth flavour, auto-system relations, env-var names, version-control workflow). The partner reads References for the docs that cover those.
- **Fact vs inference is the primary visibility split.** Plain prose = stated by the client. `🔮 inf.` tag inline = your modelling guess, every time it appears. The Data-model table carries a **Source** column (`client` / `inf.`). A partner skimming the doc must be able to see at a glance which lines they need to confirm with the client.
- **Section cross-references are functional anchor links.** Every `§N` reference must be a markdown anchor link: `[§N](#n-section-slug)`. The slug follows GitHub Flavored Markdown auto-anchoring (lowercase; spaces → hyphens; punctuation dropped; `&` removed leaving a double hyphen). A bare `§N` is unreadable to a partner skimming the doc, who just sees numbers with no way to jump.
- **No "left out" / "not named" placeholders.** The doc speaks only about content grounded in the source. A bullet that says *"sessions/programmes/schools left out on purpose"* or a section that says *"no reporting was named"* is filler: cut it. If you want to flag the absence, write it as a question in Open questions ("Are sessions/programmes first-class objects?") that gates a specific decision; otherwise, silence.
- **Never repeat yourself.** State each fact, constraint, or claim once, in its home section; elsewhere link to it (functional `[§N](...)`) rather than restate. Open questions and References are deliberate roll-ups: there, give the pointer and the decision the item gates, not a re-explanation of the body. Repetition is the main source of bloat, and two copies of a claim drift out of sync.
- **Scope altitude: name the decision, defer the mechanics.** A design doc scopes the work; it is not the technical implementation spec. State the *decision* and its *cost or scope consequence*; leave the implementation nitty-gritty (specific env-var names, runtime internals, isolation models, SDK function signatures) to the later technical phase. Example: "production automations need a sandboxed/serverless logic-function backend, an infra cost that belongs in the platform workstream" carries the quote signal; the exact `LOGIC_FUNCTION_TYPE` / `LAMBDA` / region/role/key settings do not belong in a scoping doc. Deep mechanics inflate length and date fast without changing the quote.
- **Ground everything; tag inferences `🔮 inf.`; never grow scope.** The partner quotes off this, so an invented field or requirement inflates the quote or sets a false expectation. If the *concept* is from the source but the *field name* is yours, that is an inference: tag it. Values lifted from the source (the customer's own category list becoming SELECT options) are *grounded*; only names/fields you coin are inferences.
- **Record the design consequence, not the backstory.** State a requirement and the **client's path** that follows from it; do not litigate *why* it's true. When a requirement traces to vendor-internal or third-party detail (corporate structure, legal domicile, ownership, internal commercial arrangements, who-confirms-what-with-whom), keep only the consequence: it is a commercial matter, not a build input, however much airtime it got on the call. An open item the client is waiting on is recorded as the **decision it gates** (a **❓ open** in Open questions), not as a narrative of the vendor's situation.
- **Database discipline: reuse and extend standard objects; add a custom object only at a genuine wall.** Company / Person / Opportunity plus the built-in Notes / Tasks / Timeline cover most CRM needs; every new object multiplies build and maintenance. A **human actor is a Person with a role flag before it is a new object**: create an object only when it needs its own pipeline or reporting. Name the wall when you add one.
- **When the brief is thin, under-reach the inferred model; don't fill the gap.** A blurry situation (no discovery call, sparse notes) is a reason to model the *fewest, most certain* objects and leave the rest as **❓ open** open questions, not to compensate with an elaborate inferred domain. An over-detailed inference reads as scope and cost the customer never asked for and can scare a hesitant buyer off. Lead with standard objects plus the one or two custom objects the domain unmistakably needs; everything else is a question to confirm, not a row in the table. Say plainly, up front, that the model is a minimal starting sketch to validate.
- **Present build approaches; don't prescribe.** An automation can be a no-code Workflow *or* a logic function in an app: say both. Prescribing one penalizes a partner who would do the other. The doc identifies the *need*, not the *build*.
- **Every problem carries a path; never a dead-end flag.** If you flag a constraint (e.g. dashboards can't share externally), pair it with at least one solution (CSV export, a front-component, a public site on the API) or a question that resolves it. A flag with no path is useless to someone pricing the work.
- **Flag what an approach can't satisfy.** The doc's value is surfacing walls and limits per requirement so the partner prices around them, not picking the one true solution.
- **Partner-facing voice.** Say **"Twenty," never first person** ("we / our / ours"). **No local file paths** in the output: cite shareable `docs.twenty.com` URLs only. (Customer quotes that contain "we/our" are fine: they are quotes.)
- **No characterisations or asides; only requirements and capabilities.** The doc is customer-forwardable, so keep out the source chat's off-hand remarks: characterisations of the buyer (budget, temperament, sophistication), named comparisons to competing vendors, and internal partnerships notes (deadlines, who promised what). If price-sensitivity or a competitor displacement genuinely shapes the build, state it neutrally as a requirement (e.g. cost is a selection criterion), never as a quote or judgement.
- **Verify before asserting capability.** Any "Twenty can / can't / has / lacks X" that moves the quote must be verified live (see Verification). **Undocumented ≠ impossible.**
## Formatting
- One line per paragraph: **no mid-sentence hard wraps** (they render as broken lines).
- **Never use em dashes (the long dash).** Restructure the sentence, or use a colon, comma, parentheses, or a period instead.
- **Never a bare `~`** for "approximately": GitHub markdown pairs `~...~` into strikethrough. Write "around" / "about".
- **Flags are the four emoji + text pairs only** (`🔮 inf.`, `**❓ open**`, `**⚠️ heavy**`, `**🛑 blocker**`). Don't swap the emoji or drop the text label.
- **Section cross-references are functional anchor links** (`[§N](#n-section-slug)`), never bare `§N`.
- Mark unverified capability claims `**❓ open**`, never as fact.
## Verification
Any statement of the form **"Twenty can / can't / has / lacks X"** that changes the partner's quote MUST be verified **live** before it is stated as fact. Model training is stale on a fast-moving product; the worst failure is a confident, authoritative-sounding claim that is wrong.
**Source hierarchy (what to trust, in order):**
1. **Live docs** (`docs.twenty.com`): primary truth. For the highest-stakes claims, read the page's primary text rather than trust a summary.
2. **Established Twenty SDK build patterns** (hands-on): build-level facts the customer docs omit (no formula fields; custom objects auto-get attachments/notes/tasks/timeline; two-file relations).
3. **The Twenty operator** (a Twenty team member): best for "is it shipped / internal / undocumented."
4. **Model training**: never the sole basis for a high-stakes claim.
**Right doc layer (the trap):** capabilities live in two layers, so check the right one.
- **Product capabilities** (what the CRM does for the *customer*): the **user guide + pricing page**. Covers roles / row-level permissions, dashboards, plans, hosting, data residency.
- **App capabilities** (what an *app* can define): the **developer/extend** docs. Covers field types, fields, logic functions, views, page layouts.
Checking only the app layer is how "row-level not supported" (wrong) happens: row-level is a **product feature on the Organization plan**. The converse also holds: SDK build patterns *are* sufficient to assert **build-layer** facts even when the customer docs are silent (e.g. auto system relations), so do not demote a well-established build fact to **❓ open**.
**Always verify (the load-bearing checklist), live, every run:**
1. Field types & constraints (e.g. no formula/computed fields).
2. Standard-object extension & relabeling (add fields ✓; relabel / edit built-in SELECT options?).
3. Roles & permissions: object / field / row-level, and plan-gating (which tier).
4. Dashboards & reporting: chart/widget types, beta status, export / external-sharing limits.
5. Automation surfaces: Workflows vs logic functions, what each can do.
6. Integration mechanisms: webhooks, HTTP triggers, scheduled functions, connections.
7. Hosting & deployment: cloud plans & regions / **EU data residency**, self-host availability & requirements.
Plus: any other capability claim the draft makes that carries a **⚠️ heavy** or **❓ open** flag.
**Fallback chain:**
- Docs confirm → state it as fact; record the source in References.
- Docs silent or ambiguous → ask the operator (if available).
- Operator unavailable or unsure → render it as a **❓ open** open question. **Never assert.** When you fetched a page and it was simply *silent*, record that as "docs silent (URL)" rather than leaving the claim unsourced.
**References appendix format:** a `Claim (§) | Verified against` table, then an explicit "**Could not be confirmed in public docs (check with Twenty directly):**" list. Unverified items stay **❓ open** in the body too, never silently promoted to fact. Cite the **human-readable (non-`.md`) URL** here: the `.md` twin is for *your* fetch, not for the partner (a `.md` link renders as raw markdown in a browser).
**Where to verify (Twenty doc map):** docs base = `https://docs.twenty.com/`. Fetch **`<path>.md`** for the clean markdown twin (the form to prefer). Paths:
- Product / user-guide: `user-guide/dashboards/overview` · `user-guide/dashboards/capabilities/widgets` · `user-guide/permissions-access/how-tos/permissions-faq` · `user-guide/data-model/overview` · `user-guide/data-model/capabilities/fields` · `user-guide/data-migration/how-tos/export-your-data`
- Developer / extend: `developers/extend/apps/data/objects` · `developers/extend/apps/data/extending-objects` · `developers/extend/apps/data/relations` · `developers/extend/apps/logic/logic-functions` · `developers/extend/apps/logic/connections` · `developers/extend/apps/layout/views` · `developers/extend/apps/layout/page-layouts` · `developers/extend/apps/config/roles`
- Self-host: `developers/self-host/self-host`
- Pricing & plans: `https://twenty.com/pricing` (**marketing page, no `.md`**; fetch as HTML).
If a `.md` 404s, drop the suffix or re-derive from the docs index: the map can go stale.
## Common mistakes
| Mistake | Reality / fix |
|---|---|
| "Twenty isn't a BI tool" / "can't do row-level" | Stale training. Twenty has Dashboards; row-level is on the Organization plan. **Verify live.** |
| Checked only the app-SDK doc for a product capability | Row-level lives in the product/pricing layer. **Verify the right layer.** |
| Added fields not in the source | Scope growth, wrong quote. Ground every field; tag inferences `🔮 inf.`. |
| Made a human actor (e.g. ambassador) its own object by default | A human is a Person + role flag first; an object only if it needs its own pipeline/reporting. |
| Flagged an automation as "Workflow" | Prescribes the build, penalizes app-builders. Present both. |
| Flagged a limit with no fix | Dead-end flag. Pair every problem with a path. |
| Spelled out runtime/env-var mechanics (`LOGIC_FUNCTION_TYPE` / `LAMBDA`, region/role/key, Docker version, OAuth flavour, CI/CD workflow detail) | Wrong altitude. Name the decision + its cost; defer the mechanics to the technical phase. |
| Same point restated across sections | State it once in its home section; cross-reference with a functional `[§N](...)` link. |
| Padded prose / feature-tour narration | Maximum signal per word. State the content; cut the scene-setting. |
| Added a domain-language map / glossary section | Removed. Note a genuine term collision inline in Data model; no standalone glossary. |
| First-person "not ours" in a partner doc | Say "Twenty." The doc is forwarded verbatim. |
| Local file paths in the output | Mean nothing to a partner. Cite `docs.twenty.com` only. |
| Data-model section as prose; fields modelled but not relationships | Use a per-object field **table**; model the links, not just attributes. |
| Hard-wrapped mid-sentence / used `~` / used an em dash | Broken lines / accidental strikethrough / banned dash. One line per paragraph; "around" not `~`; colon or comma, never an em dash. |
| Used an emoji other than the four canonical (🟥 / 🚩 / 🚨 / ✅), or used the emoji without the text label | Stick to `🔮 inf.`, `**❓ open**`, `**⚠️ heavy**`, `**🛑 blocker**`. The text label disambiguates. |
| Section is mostly paragraphs | Default to bullets and tables; prose only when a nuance can't fit a list. Exception: Open questions stays a numbered list. |
| Included build / runtime / SDK mechanics that don't move the quote | Wrong altitude. Business decisions, scope consequences, and References only; mechanics belong in the later technical phase. |
| Data-model table missing a Source column | Partner can't see at a glance which rows the client confirmed vs which are your inferences. Add `client` / `inf.`. |
| Cross-references are bare `§N` instead of functional links | The partner sees disconnected numbers and can't navigate. Use `[§N](#n-section-slug)` everywhere. |
| Included a section that just says "X was not named" / "no automations named" / a "left out on purpose" list | Cut the whole section / bullet. Unknowns go in Open questions; the doc speaks only about grounded content. |
| Renumbered with gaps (e.g. cut Reporting but kept §5-§11 numbering as §5, §6, §8) | Renumber sequentially, no gaps. A partner doesn't know which sections were omitted. |
| Views section prescribed the view type (table / kanban / page layout) | The partner picks the view type. List **what to surface** (objects, subsets, who they're for), not **how**. |
| Views section rendered as a bullet list | Render as a `Surface \| Shows \| Audience` table; supplement with a follow-up bullet only for a cross-cutting open question. |
| Wrote up the vendor's corporate status / legal domicile / ownership / sign-off | Backstory, not a build input. Record only the **consequence**: requirement → the client's path; gate the open item as **❓ open** in Open questions. |
| Filled a thin brief with an elaborate inferred model | Over-reach scares a hesitant buyer with unrequested scope. Model the few certain objects; flag the rest as **❓ open**; say it's a minimal sketch. |
| Added an Integrations section with connectors the customer never named | Integrations is conditional, not default. Omit it absent a named system; at most flag one **❓ open**. |
| Kept buyer characterisations / competitor asides / internal timelines | Not customer-safe. State needs neutrally; cut the rest. |
| Listed source materials / who-promised-what in the header, or stacked it as bold lines | Header is a four-field table. Not customer-safe content goes nowhere. |
@@ -10,6 +10,7 @@ export default defineView({
name: 'Partners',
objectUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
type: ViewType.TABLE,
position: 2,
fields: [
{ universalIdentifier: '21afcc69-09c5-42eb-a609-26c062de3bd3', fieldMetadataUniversalIdentifier: 'a0000001-0000-4000-8000-000000000001', position: 0, isVisible: true },
{ universalIdentifier: '529912f0-38fb-4821-92d3-8a0a68b9f340', fieldMetadataUniversalIdentifier: '2ca9856f-f54a-4326-9ff3-668fd7da0b50', position: 1, isVisible: true },
@@ -17,6 +18,6 @@ export default defineView({
{ universalIdentifier: '8862e4a5-525a-4a0c-8381-93ff0d01ccf0', fieldMetadataUniversalIdentifier: 'a0000007-0000-4000-8000-000000000007', position: 3, isVisible: true },
{ universalIdentifier: '4ebe0b9d-0c2d-4416-b187-150b02473a01', fieldMetadataUniversalIdentifier: 'a0000010-0000-4000-8000-000000000010', position: 4, isVisible: true },
{ universalIdentifier: '52408b5f-5e13-4e3c-af2d-ce50033ec126', fieldMetadataUniversalIdentifier: '560503de-6330-4c1d-af97-a8dee125f2ad', position: 5, isVisible: true },
{ universalIdentifier: '02cc471b-e9ba-4643-b403-40299d6bbbdd', fieldMetadataUniversalIdentifier: 'a0000005-0000-4000-8000-000000000005', position: 6, isVisible: true },
{ universalIdentifier: '34d58668-a689-42e2-9aff-3fee315092d6', fieldMetadataUniversalIdentifier: '500021ad-ca42-4fd3-8727-392dd26b722a', position: 6, isVisible: true },
],
});
@@ -12,6 +12,7 @@ export default defineView({
icon: 'IconUserPlus',
objectUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
type: ViewType.TABLE,
position: 1,
fields: [
{ universalIdentifier: 'b4f505d7-3849-4a74-a27f-1c91733702b5', fieldMetadataUniversalIdentifier: 'a0000001-0000-4000-8000-000000000001', position: 0, isVisible: true },
{ universalIdentifier: '8a39e510-e533-4cd7-9b65-5e16b5f773d0', fieldMetadataUniversalIdentifier: '2ca9856f-f54a-4326-9ff3-668fd7da0b50', position: 1, isVisible: true },
@@ -12,12 +12,14 @@ export default defineView({
icon: 'IconCircleCheck',
objectUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
type: ViewType.TABLE,
position: 0,
fields: [
{ universalIdentifier: '9463bf58-69d5-4309-bc6e-4835df346246', fieldMetadataUniversalIdentifier: 'a0000001-0000-4000-8000-000000000001', position: 0, isVisible: true },
{ universalIdentifier: 'c8fd88c5-ffa9-4944-b5b9-deec3acd3dff', fieldMetadataUniversalIdentifier: 'd4fa6461-37b6-49ee-9181-dd482e74a70b', position: 1, isVisible: true },
{ universalIdentifier: '9cb93542-2a91-4e75-a9f8-4a1866445322', fieldMetadataUniversalIdentifier: '500021ad-ca42-4fd3-8727-392dd26b722a', position: 2, isVisible: true },
{ universalIdentifier: 'd6df98ac-9c6c-46c2-8784-d4e1d6521f75', fieldMetadataUniversalIdentifier: '560503de-6330-4c1d-af97-a8dee125f2ad', position: 3, isVisible: true },
{ universalIdentifier: '375cc871-fdda-42d0-8308-e60174b6d467', fieldMetadataUniversalIdentifier: 'a0000004-0000-4000-8000-000000000004', position: 4, isVisible: true },
{ universalIdentifier: '6a7d6b14-9216-42af-bbd9-89bd185fd492', fieldMetadataUniversalIdentifier: 'a0000007-0000-4000-8000-000000000007', position: 5, isVisible: true },
],
filters: [
{
@@ -0,0 +1,15 @@
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config';
// Pure unit tests — no server required, no globalSetup.
export default defineConfig({
plugins: [
tsconfigPaths({
projects: ['tsconfig.spec.json'],
ignoreConfigErrors: true,
}),
],
test: {
include: ['src/**/*.test.ts'],
},
});
@@ -4086,6 +4086,7 @@ __metadata:
typescript: "npm:^5.9.3"
vite-tsconfig-paths: "npm:^4.2.1"
vitest: "npm:^3.1.1"
zod: "npm:^4.1.11"
languageName: unknown
linkType: soft
+1 -1
View File
@@ -49,7 +49,7 @@
"@typescript/native-preview": "^7.0.0-dev.20260116.1",
"tsc-alias": "^1.8.16",
"twenty-shared": "workspace:*",
"typescript": "^5.9.2",
"typescript": "^5.9.3",
"vite": "^7.0.0",
"vite-plugin-dts": "^4.5.4",
"vite-tsconfig-paths": "^4.2.1",
@@ -441,6 +441,7 @@ type LogicFunction {
description: String
runtime: String!
timeoutSeconds: Float!
executionMode: LogicFunctionExecutionMode!
sourceHandlerPath: String!
handlerName: String!
cronTriggerSettings: JSON
@@ -454,6 +455,11 @@ type LogicFunction {
updatedAt: DateTime!
}
enum LogicFunctionExecutionMode {
LIVE
PREBUILT
}
type StandardOverrides {
label: String
description: String
@@ -1100,7 +1106,7 @@ type PageLayoutWidgetCanvasPosition {
layoutMode: PageLayoutTabLayoutMode!
}
union WidgetConfiguration = AggregateChartConfiguration | StandaloneRichTextConfiguration | PieChartConfiguration | LineChartConfiguration | IframeConfiguration | GaugeChartConfiguration | BarChartConfiguration | CalendarConfiguration | FrontComponentConfiguration | EmailsConfiguration | EmailThreadConfiguration | FieldConfiguration | FieldRichTextConfiguration | FieldsConfiguration | FilesConfiguration | NotesConfiguration | TasksConfiguration | TimelineConfiguration | ViewConfiguration | RecordTableConfiguration | WorkflowConfiguration | WorkflowRunConfiguration | WorkflowVersionConfiguration
union WidgetConfiguration = AggregateChartConfiguration | StandaloneRichTextConfiguration | PieChartConfiguration | LineChartConfiguration | IframeConfiguration | BarChartConfiguration | CalendarConfiguration | FrontComponentConfiguration | EmailsConfiguration | EmailThreadConfiguration | FieldConfiguration | FieldRichTextConfiguration | FieldsConfiguration | FilesConfiguration | NotesConfiguration | TasksConfiguration | TimelineConfiguration | ViewConfiguration | RecordTableConfiguration | WorkflowConfiguration | WorkflowRunConfiguration | WorkflowVersionConfiguration
type AggregateChartConfiguration {
configurationType: WidgetConfigurationType!
@@ -1120,7 +1126,6 @@ type AggregateChartConfiguration {
enum WidgetConfigurationType {
AGGREGATE_CHART
GAUGE_CHART
PIE_CHART
BAR_CHART
LINE_CHART
@@ -1239,18 +1244,6 @@ type IframeConfiguration {
url: String
}
type GaugeChartConfiguration {
configurationType: WidgetConfigurationType!
aggregateFieldMetadataId: UUID!
aggregateOperation: AggregateOperations!
displayDataLabel: Boolean
color: String
description: String
filter: JSON
timezone: String
firstDayOfTheWeek: Int
}
type BarChartConfiguration {
configurationType: WidgetConfigurationType!
aggregateFieldMetadataId: UUID!
@@ -1315,6 +1308,7 @@ type FieldConfiguration {
configurationType: WidgetConfigurationType!
fieldMetadataId: String!
fieldDisplayMode: FieldDisplayMode!
viewId: String
}
"""Display mode for field configuration widgets"""
@@ -1323,6 +1317,7 @@ enum FieldDisplayMode {
EDITOR
FIELD
VIEW
TABLE
}
type FieldRichTextConfiguration {
@@ -1444,6 +1439,39 @@ type Analytics {
success: Boolean!
}
type VerificationRecord {
type: String!
key: String!
value: String!
priority: Float
}
type EmailingDomain {
id: UUID!
createdAt: DateTime!
updatedAt: DateTime!
domain: String!
driver: EmailingDomainDriver!
status: EmailingDomainStatus!
verificationRecords: [VerificationRecord!]
verifiedAt: DateTime
}
enum EmailingDomainDriver {
AWS_SES
}
enum EmailingDomainStatus {
PENDING
VERIFIED
FAILED
TEMPORARY_FAILURE
}
type SendEmailViaDomainOutput {
messageId: String!
}
type ApprovedAccessDomain {
id: UUID!
domain: String!
@@ -1754,10 +1782,11 @@ enum FeatureFlagKey {
IS_JSON_FILTER_ENABLED
IS_MARKETPLACE_SETTING_TAB_VISIBLE
IS_PUBLIC_DOMAIN_ENABLED
IS_EMAILING_DOMAIN_ENABLED
IS_EMAIL_GROUP_ENABLED
IS_JUNCTION_RELATIONS_ENABLED
IS_REST_METADATA_API_NEW_FORMAT_DIRECT
IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED
IS_SETTINGS_DISCOVERY_HERO_ENABLED
}
type WorkspaceUrls {
@@ -2371,35 +2400,6 @@ type PublicDomain {
createdAt: DateTime!
}
type VerificationRecord {
type: String!
key: String!
value: String!
priority: Float
}
type EmailingDomain {
id: UUID!
createdAt: DateTime!
updatedAt: DateTime!
domain: String!
driver: EmailingDomainDriver!
status: EmailingDomainStatus!
verificationRecords: [VerificationRecord!]
verifiedAt: DateTime
}
enum EmailingDomainDriver {
AWS_SES
}
enum EmailingDomainStatus {
PENDING
VERIFIED
FAILED
TEMPORARY_FAILURE
}
type AutocompleteResult {
text: String!
placeId: String!
@@ -2707,6 +2707,12 @@ type AgentTurn {
createdAt: DateTime!
}
type WorkspaceAiStats {
conversationsCount: Int!
skillsCount: Int!
toolsCount: Int!
}
type CalendarChannel {
id: UUID!
handle: String!
@@ -2944,6 +2950,7 @@ type Query {
getPageLayoutTab(id: String!): PageLayoutTab!
getPageLayouts(objectMetadataId: String, pageLayoutType: PageLayoutType): [PageLayout!]!
getPageLayout(id: String!): PageLayout
getEmailingDomains: [EmailingDomain!]!
applicationConnectionProviders(applicationId: UUID!): [ApplicationConnectionProvider!]!
getPageLayoutWidgets(pageLayoutTabId: String!): [PageLayoutWidget!]!
getPageLayoutWidget(id: String!): PageLayoutWidget!
@@ -2980,6 +2987,7 @@ type Query {
): IndexConnection!
findManyAgents: [Agent!]!
findOneAgent(input: AgentIdInput!): Agent!
myConnectedAccounts: [ConnectedAccountPublicDTO!]!
getRoles: [Role!]!
getToolIndex: [ToolIndexEntry!]!
getToolInputSchema(toolName: String!): JSON
@@ -3000,9 +3008,9 @@ type Query {
getViewGroup(id: String!): ViewGroup
myMessageFolders(messageChannelId: UUID): [MessageFolder!]!
myMessageChannels(connectedAccountId: UUID): [MessageChannel!]!
myConnectedAccounts: [ConnectedAccountPublicDTO!]!
myCalendarChannels(connectedAccountId: UUID): [CalendarChannel!]!
minimalMetadata: MinimalMetadata!
findWorkspaceAiStats: WorkspaceAiStats!
chatThreads: [AgentChatThread!]!
chatThread(id: UUID!): AgentChatThread!
chatMessages(threadId: UUID!): [AgentMessage!]!
@@ -3038,7 +3046,6 @@ type Query {
getAddressDetails(placeId: String!, token: String!): PlaceDetailsResult!
getUsageAnalytics(input: UsageAnalyticsInput): UsageAnalytics!
findManyPublicDomains: [PublicDomain!]!
getEmailingDomains: [EmailingDomain!]!
findManyMarketplaceApps: [MarketplaceApp!]!
findMarketplaceAppDetail(universalIdentifier: String!): MarketplaceAppDetail!
}
@@ -3193,6 +3200,10 @@ type Mutation {
resetPageLayoutToDefault(id: String!): PageLayout!
resetPageLayoutWidgetToDefault(id: String!): PageLayoutWidget!
resetPageLayoutTabToDefault(id: String!): PageLayoutTab!
createEmailingDomain(domain: String!, driver: EmailingDomainDriver!): EmailingDomain!
deleteEmailingDomain(id: String!): Boolean!
verifyEmailingDomain(id: String!): EmailingDomain!
sendEmailViaEmailingDomain(input: SendEmailViaDomainInput!): SendEmailViaDomainOutput!
updateOneApplicationVariable(key: String!, value: String!, applicationId: UUID!): Boolean!
createPageLayoutWidget(input: CreatePageLayoutWidgetInput!): PageLayoutWidget!
updatePageLayoutWidget(id: String!, input: UpdatePageLayoutWidgetInput!): PageLayoutWidget!
@@ -3215,6 +3226,7 @@ type Mutation {
createOneAgent(input: CreateAgentInput!): Agent!
updateOneAgent(input: UpdateAgentInput!): Agent!
deleteOneAgent(input: AgentIdInput!): Agent!
deleteConnectedAccount(id: UUID!): ConnectedAccountPublicDTO!
updateWorkspaceMemberRole(workspaceMemberId: UUID!, roleId: UUID!): WorkspaceMember!
createOneRole(createRoleInput: CreateRoleInput!): Role!
updateOneRole(updateRoleInput: UpdateRoleInput!): Role!
@@ -3242,7 +3254,6 @@ type Mutation {
updateMessageChannel(input: UpdateMessageChannelInput!): MessageChannel!
createEmailGroupChannel(input: CreateEmailGroupChannelInput!): CreateEmailGroupChannelOutput!
deleteEmailGroupChannel(id: UUID!): MessageChannel!
deleteConnectedAccount(id: UUID!): ConnectedAccountPublicDTO!
updateCalendarChannel(input: UpdateCalendarChannelInput!): CalendarChannel!
createChatThread: AgentChatThread!
sendChatMessage(threadId: UUID!, text: String!, messageId: UUID!, browsingContext: JSON, modelId: String, fileAttachments: [FileAttachmentInput!]): SendChatMessageResult!
@@ -3273,6 +3284,7 @@ type Mutation {
authorizeApp(clientId: String!, codeChallenge: String, redirectUrl: String!, state: String, scope: String): AuthorizeApp!
renewToken(appToken: String!): AuthTokens!
generateApiKeyToken(apiKeyId: UUID!, expiresAt: String!): ApiKeyToken!
generatePlaygroundToken: AuthToken!
emailPasswordResetLink(email: String!, workspaceId: UUID): EmailPasswordResetLink!
updatePasswordViaResetToken(passwordResetToken: String!, newPassword: String!): InvalidatePassword!
createApplicationRegistration(input: CreateApplicationRegistrationInput!): CreateApplicationRegistration!
@@ -3313,9 +3325,6 @@ type Mutation {
updatePublicDomain(domain: String!, applicationId: String): PublicDomain!
deletePublicDomain(domain: String!): Boolean!
checkPublicDomainValidRecords(domain: String!): DomainValidRecords
createEmailingDomain(domain: String!, driver: EmailingDomainDriver!): EmailingDomain!
deleteEmailingDomain(id: String!): Boolean!
verifyEmailingDomain(id: String!): EmailingDomain!
createOneAppToken(input: CreateOneAppTokenInput!): AppToken!
installMarketplaceApp(universalIdentifier: String!, version: String): Boolean! @deprecated(reason: "Use installApplication instead")
installApplication(universalIdentifier: String!, version: String): Application!
@@ -3759,6 +3768,18 @@ input GridPositionInput {
columnSpan: Float!
}
input SendEmailViaDomainInput {
emailingDomainId: String!
to: [String!]!
cc: [String!]
bcc: [String!]
subject: String!
text: String!
html: String
from: String!
replyTo: [String!]
}
input CreatePageLayoutWidgetInput {
pageLayoutTabId: UUID!
title: String!
@@ -326,6 +326,7 @@ export interface LogicFunction {
description?: Scalars['String']
runtime: Scalars['String']
timeoutSeconds: Scalars['Float']
executionMode: LogicFunctionExecutionMode
sourceHandlerPath: Scalars['String']
handlerName: Scalars['String']
cronTriggerSettings?: Scalars['JSON']
@@ -340,6 +341,8 @@ export interface LogicFunction {
__typename: 'LogicFunction'
}
export type LogicFunctionExecutionMode = 'LIVE' | 'PREBUILT'
export interface StandardOverrides {
label?: Scalars['String']
description?: Scalars['String']
@@ -790,7 +793,7 @@ export interface PageLayoutWidgetCanvasPosition {
__typename: 'PageLayoutWidgetCanvasPosition'
}
export type WidgetConfiguration = (AggregateChartConfiguration | StandaloneRichTextConfiguration | PieChartConfiguration | LineChartConfiguration | IframeConfiguration | GaugeChartConfiguration | BarChartConfiguration | CalendarConfiguration | FrontComponentConfiguration | EmailsConfiguration | EmailThreadConfiguration | FieldConfiguration | FieldRichTextConfiguration | FieldsConfiguration | FilesConfiguration | NotesConfiguration | TasksConfiguration | TimelineConfiguration | ViewConfiguration | RecordTableConfiguration | WorkflowConfiguration | WorkflowRunConfiguration | WorkflowVersionConfiguration) & { __isUnion?: true }
export type WidgetConfiguration = (AggregateChartConfiguration | StandaloneRichTextConfiguration | PieChartConfiguration | LineChartConfiguration | IframeConfiguration | BarChartConfiguration | CalendarConfiguration | FrontComponentConfiguration | EmailsConfiguration | EmailThreadConfiguration | FieldConfiguration | FieldRichTextConfiguration | FieldsConfiguration | FilesConfiguration | NotesConfiguration | TasksConfiguration | TimelineConfiguration | ViewConfiguration | RecordTableConfiguration | WorkflowConfiguration | WorkflowRunConfiguration | WorkflowVersionConfiguration) & { __isUnion?: true }
export interface AggregateChartConfiguration {
configurationType: WidgetConfigurationType
@@ -809,7 +812,7 @@ export interface AggregateChartConfiguration {
__typename: 'AggregateChartConfiguration'
}
export type WidgetConfigurationType = 'AGGREGATE_CHART' | 'GAUGE_CHART' | 'PIE_CHART' | 'BAR_CHART' | 'LINE_CHART' | 'IFRAME' | 'STANDALONE_RICH_TEXT' | 'VIEW' | 'FIELD' | 'FIELDS' | 'TIMELINE' | 'TASKS' | 'NOTES' | 'FILES' | 'EMAILS' | 'CALENDAR' | 'FIELD_RICH_TEXT' | 'WORKFLOW' | 'WORKFLOW_VERSION' | 'WORKFLOW_RUN' | 'FRONT_COMPONENT' | 'RECORD_TABLE' | 'EMAIL_THREAD'
export type WidgetConfigurationType = 'AGGREGATE_CHART' | 'PIE_CHART' | 'BAR_CHART' | 'LINE_CHART' | 'IFRAME' | 'STANDALONE_RICH_TEXT' | 'VIEW' | 'FIELD' | 'FIELDS' | 'TIMELINE' | 'TASKS' | 'NOTES' | 'FILES' | 'EMAILS' | 'CALENDAR' | 'FIELD_RICH_TEXT' | 'WORKFLOW' | 'WORKFLOW_VERSION' | 'WORKFLOW_RUN' | 'FRONT_COMPONENT' | 'RECORD_TABLE' | 'EMAIL_THREAD'
export interface StandaloneRichTextConfiguration {
configurationType: WidgetConfigurationType
@@ -888,19 +891,6 @@ export interface IframeConfiguration {
__typename: 'IframeConfiguration'
}
export interface GaugeChartConfiguration {
configurationType: WidgetConfigurationType
aggregateFieldMetadataId: Scalars['UUID']
aggregateOperation: AggregateOperations
displayDataLabel?: Scalars['Boolean']
color?: Scalars['String']
description?: Scalars['String']
filter?: Scalars['JSON']
timezone?: Scalars['String']
firstDayOfTheWeek?: Scalars['Int']
__typename: 'GaugeChartConfiguration'
}
export interface BarChartConfiguration {
configurationType: WidgetConfigurationType
aggregateFieldMetadataId: Scalars['UUID']
@@ -966,12 +956,13 @@ export interface FieldConfiguration {
configurationType: WidgetConfigurationType
fieldMetadataId: Scalars['String']
fieldDisplayMode: FieldDisplayMode
viewId?: Scalars['String']
__typename: 'FieldConfiguration'
}
/** Display mode for field configuration widgets */
export type FieldDisplayMode = 'CARD' | 'EDITOR' | 'FIELD' | 'VIEW'
export type FieldDisplayMode = 'CARD' | 'EDITOR' | 'FIELD' | 'VIEW' | 'TABLE'
export interface FieldRichTextConfiguration {
configurationType: WidgetConfigurationType
@@ -1106,6 +1097,35 @@ export interface Analytics {
__typename: 'Analytics'
}
export interface VerificationRecord {
type: Scalars['String']
key: Scalars['String']
value: Scalars['String']
priority?: Scalars['Float']
__typename: 'VerificationRecord'
}
export interface EmailingDomain {
id: Scalars['UUID']
createdAt: Scalars['DateTime']
updatedAt: Scalars['DateTime']
domain: Scalars['String']
driver: EmailingDomainDriver
status: EmailingDomainStatus
verificationRecords?: VerificationRecord[]
verifiedAt?: Scalars['DateTime']
__typename: 'EmailingDomain'
}
export type EmailingDomainDriver = 'AWS_SES'
export type EmailingDomainStatus = 'PENDING' | 'VERIFIED' | 'FAILED' | 'TEMPORARY_FAILURE'
export interface SendEmailViaDomainOutput {
messageId: Scalars['String']
__typename: 'SendEmailViaDomainOutput'
}
export interface ApprovedAccessDomain {
id: Scalars['UUID']
domain: Scalars['String']
@@ -1394,7 +1414,7 @@ export interface FeatureFlag {
__typename: 'FeatureFlag'
}
export type FeatureFlagKey = 'IS_UNIQUE_INDEXES_ENABLED' | 'IS_JSON_FILTER_ENABLED' | 'IS_MARKETPLACE_SETTING_TAB_VISIBLE' | 'IS_PUBLIC_DOMAIN_ENABLED' | 'IS_EMAILING_DOMAIN_ENABLED' | 'IS_EMAIL_GROUP_ENABLED' | 'IS_JUNCTION_RELATIONS_ENABLED' | 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT'
export type FeatureFlagKey = 'IS_UNIQUE_INDEXES_ENABLED' | 'IS_JSON_FILTER_ENABLED' | 'IS_MARKETPLACE_SETTING_TAB_VISIBLE' | 'IS_PUBLIC_DOMAIN_ENABLED' | 'IS_EMAIL_GROUP_ENABLED' | 'IS_JUNCTION_RELATIONS_ENABLED' | 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT' | 'IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED' | 'IS_SETTINGS_DISCOVERY_HERO_ENABLED'
export interface WorkspaceUrls {
customUrl?: Scalars['String']
@@ -2050,30 +2070,6 @@ export interface PublicDomain {
__typename: 'PublicDomain'
}
export interface VerificationRecord {
type: Scalars['String']
key: Scalars['String']
value: Scalars['String']
priority?: Scalars['Float']
__typename: 'VerificationRecord'
}
export interface EmailingDomain {
id: Scalars['UUID']
createdAt: Scalars['DateTime']
updatedAt: Scalars['DateTime']
domain: Scalars['String']
driver: EmailingDomainDriver
status: EmailingDomainStatus
verificationRecords?: VerificationRecord[]
verifiedAt?: Scalars['DateTime']
__typename: 'EmailingDomain'
}
export type EmailingDomainDriver = 'AWS_SES'
export type EmailingDomainStatus = 'PENDING' | 'VERIFIED' | 'FAILED' | 'TEMPORARY_FAILURE'
export interface AutocompleteResult {
text: Scalars['String']
placeId: Scalars['String']
@@ -2417,6 +2413,13 @@ export interface AgentTurn {
__typename: 'AgentTurn'
}
export interface WorkspaceAiStats {
conversationsCount: Scalars['Int']
skillsCount: Scalars['Int']
toolsCount: Scalars['Int']
__typename: 'WorkspaceAiStats'
}
export interface CalendarChannel {
id: Scalars['UUID']
handle: Scalars['String']
@@ -2571,6 +2574,7 @@ export interface Query {
getPageLayoutTab: PageLayoutTab
getPageLayouts: PageLayout[]
getPageLayout?: PageLayout
getEmailingDomains: EmailingDomain[]
applicationConnectionProviders: ApplicationConnectionProvider[]
getPageLayoutWidgets: PageLayoutWidget[]
getPageLayoutWidget: PageLayoutWidget
@@ -2589,6 +2593,7 @@ export interface Query {
indexMetadatas: IndexConnection
findManyAgents: Agent[]
findOneAgent: Agent
myConnectedAccounts: ConnectedAccountPublicDTO[]
getRoles: Role[]
getToolIndex: ToolIndexEntry[]
getToolInputSchema?: Scalars['JSON']
@@ -2600,9 +2605,9 @@ export interface Query {
getViewGroup?: ViewGroup
myMessageFolders: MessageFolder[]
myMessageChannels: MessageChannel[]
myConnectedAccounts: ConnectedAccountPublicDTO[]
myCalendarChannels: CalendarChannel[]
minimalMetadata: MinimalMetadata
findWorkspaceAiStats: WorkspaceAiStats
chatThreads: AgentChatThread[]
chatThread: AgentChatThread
chatMessages: AgentMessage[]
@@ -2638,7 +2643,6 @@ export interface Query {
getAddressDetails: PlaceDetailsResult
getUsageAnalytics: UsageAnalytics
findManyPublicDomains: PublicDomain[]
getEmailingDomains: EmailingDomain[]
findManyMarketplaceApps: MarketplaceApp[]
findMarketplaceAppDetail: MarketplaceAppDetail
__typename: 'Query'
@@ -2726,6 +2730,10 @@ export interface Mutation {
resetPageLayoutToDefault: PageLayout
resetPageLayoutWidgetToDefault: PageLayoutWidget
resetPageLayoutTabToDefault: PageLayoutTab
createEmailingDomain: EmailingDomain
deleteEmailingDomain: Scalars['Boolean']
verifyEmailingDomain: EmailingDomain
sendEmailViaEmailingDomain: SendEmailViaDomainOutput
updateOneApplicationVariable: Scalars['Boolean']
createPageLayoutWidget: PageLayoutWidget
updatePageLayoutWidget: PageLayoutWidget
@@ -2748,6 +2756,7 @@ export interface Mutation {
createOneAgent: Agent
updateOneAgent: Agent
deleteOneAgent: Agent
deleteConnectedAccount: ConnectedAccountPublicDTO
updateWorkspaceMemberRole: WorkspaceMember
createOneRole: Role
updateOneRole: Role
@@ -2775,7 +2784,6 @@ export interface Mutation {
updateMessageChannel: MessageChannel
createEmailGroupChannel: CreateEmailGroupChannelOutput
deleteEmailGroupChannel: MessageChannel
deleteConnectedAccount: ConnectedAccountPublicDTO
updateCalendarChannel: CalendarChannel
createChatThread: AgentChatThread
sendChatMessage: SendChatMessageResult
@@ -2806,6 +2814,7 @@ export interface Mutation {
authorizeApp: AuthorizeApp
renewToken: AuthTokens
generateApiKeyToken: ApiKeyToken
generatePlaygroundToken: AuthToken
emailPasswordResetLink: EmailPasswordResetLink
updatePasswordViaResetToken: InvalidatePassword
createApplicationRegistration: CreateApplicationRegistration
@@ -2846,9 +2855,6 @@ export interface Mutation {
updatePublicDomain: PublicDomain
deletePublicDomain: Scalars['Boolean']
checkPublicDomainValidRecords?: DomainValidRecords
createEmailingDomain: EmailingDomain
deleteEmailingDomain: Scalars['Boolean']
verifyEmailingDomain: EmailingDomain
createOneAppToken: AppToken
/** @deprecated Use installApplication instead */
installMarketplaceApp: Scalars['Boolean']
@@ -3201,6 +3207,7 @@ export interface LogicFunctionGenqlSelection{
description?: boolean | number
runtime?: boolean | number
timeoutSeconds?: boolean | number
executionMode?: boolean | number
sourceHandlerPath?: boolean | number
handlerName?: boolean | number
cronTriggerSettings?: boolean | number
@@ -3703,7 +3710,6 @@ export interface WidgetConfigurationGenqlSelection{
on_PieChartConfiguration?:PieChartConfigurationGenqlSelection,
on_LineChartConfiguration?:LineChartConfigurationGenqlSelection,
on_IframeConfiguration?:IframeConfigurationGenqlSelection,
on_GaugeChartConfiguration?:GaugeChartConfigurationGenqlSelection,
on_BarChartConfiguration?:BarChartConfigurationGenqlSelection,
on_CalendarConfiguration?:CalendarConfigurationGenqlSelection,
on_FrontComponentConfiguration?:FrontComponentConfigurationGenqlSelection,
@@ -3811,20 +3817,6 @@ export interface IframeConfigurationGenqlSelection{
__scalar?: boolean | number
}
export interface GaugeChartConfigurationGenqlSelection{
configurationType?: boolean | number
aggregateFieldMetadataId?: boolean | number
aggregateOperation?: boolean | number
displayDataLabel?: boolean | number
color?: boolean | number
description?: boolean | number
filter?: boolean | number
timezone?: boolean | number
firstDayOfTheWeek?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface BarChartConfigurationGenqlSelection{
configurationType?: boolean | number
aggregateFieldMetadataId?: boolean | number
@@ -3887,6 +3879,7 @@ export interface FieldConfigurationGenqlSelection{
configurationType?: boolean | number
fieldMetadataId?: boolean | number
fieldDisplayMode?: boolean | number
viewId?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
@@ -4040,6 +4033,34 @@ export interface AnalyticsGenqlSelection{
__scalar?: boolean | number
}
export interface VerificationRecordGenqlSelection{
type?: boolean | number
key?: boolean | number
value?: boolean | number
priority?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface EmailingDomainGenqlSelection{
id?: boolean | number
createdAt?: boolean | number
updatedAt?: boolean | number
domain?: boolean | number
driver?: boolean | number
status?: boolean | number
verificationRecords?: VerificationRecordGenqlSelection
verifiedAt?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface SendEmailViaDomainOutputGenqlSelection{
messageId?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface ApprovedAccessDomainGenqlSelection{
id?: boolean | number
domain?: boolean | number
@@ -5051,28 +5072,6 @@ export interface PublicDomainGenqlSelection{
__scalar?: boolean | number
}
export interface VerificationRecordGenqlSelection{
type?: boolean | number
key?: boolean | number
value?: boolean | number
priority?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface EmailingDomainGenqlSelection{
id?: boolean | number
createdAt?: boolean | number
updatedAt?: boolean | number
domain?: boolean | number
driver?: boolean | number
status?: boolean | number
verificationRecords?: VerificationRecordGenqlSelection
verifiedAt?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface AutocompleteResultGenqlSelection{
text?: boolean | number
placeId?: boolean | number
@@ -5452,6 +5451,14 @@ export interface AgentTurnGenqlSelection{
__scalar?: boolean | number
}
export interface WorkspaceAiStatsGenqlSelection{
conversationsCount?: boolean | number
skillsCount?: boolean | number
toolsCount?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface CalendarChannelGenqlSelection{
id?: boolean | number
handle?: boolean | number
@@ -5588,6 +5595,7 @@ export interface QueryGenqlSelection{
getPageLayoutTab?: (PageLayoutTabGenqlSelection & { __args: {id: Scalars['String']} })
getPageLayouts?: (PageLayoutGenqlSelection & { __args?: {objectMetadataId?: (Scalars['String'] | null), pageLayoutType?: (PageLayoutType | null)} })
getPageLayout?: (PageLayoutGenqlSelection & { __args: {id: Scalars['String']} })
getEmailingDomains?: EmailingDomainGenqlSelection
applicationConnectionProviders?: (ApplicationConnectionProviderGenqlSelection & { __args: {applicationId: Scalars['UUID']} })
getPageLayoutWidgets?: (PageLayoutWidgetGenqlSelection & { __args: {pageLayoutTabId: Scalars['String']} })
getPageLayoutWidget?: (PageLayoutWidgetGenqlSelection & { __args: {id: Scalars['String']} })
@@ -5618,6 +5626,7 @@ export interface QueryGenqlSelection{
filter: IndexFilter} })
findManyAgents?: AgentGenqlSelection
findOneAgent?: (AgentGenqlSelection & { __args: {input: AgentIdInput} })
myConnectedAccounts?: ConnectedAccountPublicDTOGenqlSelection
getRoles?: RoleGenqlSelection
getToolIndex?: ToolIndexEntryGenqlSelection
getToolInputSchema?: { __args: {toolName: Scalars['String']} }
@@ -5635,9 +5644,9 @@ export interface QueryGenqlSelection{
getViewGroup?: (ViewGroupGenqlSelection & { __args: {id: Scalars['String']} })
myMessageFolders?: (MessageFolderGenqlSelection & { __args?: {messageChannelId?: (Scalars['UUID'] | null)} })
myMessageChannels?: (MessageChannelGenqlSelection & { __args?: {connectedAccountId?: (Scalars['UUID'] | null)} })
myConnectedAccounts?: ConnectedAccountPublicDTOGenqlSelection
myCalendarChannels?: (CalendarChannelGenqlSelection & { __args?: {connectedAccountId?: (Scalars['UUID'] | null)} })
minimalMetadata?: MinimalMetadataGenqlSelection
findWorkspaceAiStats?: WorkspaceAiStatsGenqlSelection
chatThreads?: AgentChatThreadGenqlSelection
chatThread?: (AgentChatThreadGenqlSelection & { __args: {id: Scalars['UUID']} })
chatMessages?: (AgentMessageGenqlSelection & { __args: {threadId: Scalars['UUID']} })
@@ -5673,7 +5682,6 @@ export interface QueryGenqlSelection{
getAddressDetails?: (PlaceDetailsResultGenqlSelection & { __args: {placeId: Scalars['String'], token: Scalars['String']} })
getUsageAnalytics?: (UsageAnalyticsGenqlSelection & { __args?: {input?: (UsageAnalyticsInput | null)} })
findManyPublicDomains?: PublicDomainGenqlSelection
getEmailingDomains?: EmailingDomainGenqlSelection
findManyMarketplaceApps?: MarketplaceAppGenqlSelection
findMarketplaceAppDetail?: (MarketplaceAppDetailGenqlSelection & { __args: {universalIdentifier: Scalars['String']} })
__typename?: boolean | number
@@ -5782,6 +5790,10 @@ export interface MutationGenqlSelection{
resetPageLayoutToDefault?: (PageLayoutGenqlSelection & { __args: {id: Scalars['String']} })
resetPageLayoutWidgetToDefault?: (PageLayoutWidgetGenqlSelection & { __args: {id: Scalars['String']} })
resetPageLayoutTabToDefault?: (PageLayoutTabGenqlSelection & { __args: {id: Scalars['String']} })
createEmailingDomain?: (EmailingDomainGenqlSelection & { __args: {domain: Scalars['String'], driver: EmailingDomainDriver} })
deleteEmailingDomain?: { __args: {id: Scalars['String']} }
verifyEmailingDomain?: (EmailingDomainGenqlSelection & { __args: {id: Scalars['String']} })
sendEmailViaEmailingDomain?: (SendEmailViaDomainOutputGenqlSelection & { __args: {input: SendEmailViaDomainInput} })
updateOneApplicationVariable?: { __args: {key: Scalars['String'], value: Scalars['String'], applicationId: Scalars['UUID']} }
createPageLayoutWidget?: (PageLayoutWidgetGenqlSelection & { __args: {input: CreatePageLayoutWidgetInput} })
updatePageLayoutWidget?: (PageLayoutWidgetGenqlSelection & { __args: {id: Scalars['String'], input: UpdatePageLayoutWidgetInput} })
@@ -5804,6 +5816,7 @@ export interface MutationGenqlSelection{
createOneAgent?: (AgentGenqlSelection & { __args: {input: CreateAgentInput} })
updateOneAgent?: (AgentGenqlSelection & { __args: {input: UpdateAgentInput} })
deleteOneAgent?: (AgentGenqlSelection & { __args: {input: AgentIdInput} })
deleteConnectedAccount?: (ConnectedAccountPublicDTOGenqlSelection & { __args: {id: Scalars['UUID']} })
updateWorkspaceMemberRole?: (WorkspaceMemberGenqlSelection & { __args: {workspaceMemberId: Scalars['UUID'], roleId: Scalars['UUID']} })
createOneRole?: (RoleGenqlSelection & { __args: {createRoleInput: CreateRoleInput} })
updateOneRole?: (RoleGenqlSelection & { __args: {updateRoleInput: UpdateRoleInput} })
@@ -5831,7 +5844,6 @@ export interface MutationGenqlSelection{
updateMessageChannel?: (MessageChannelGenqlSelection & { __args: {input: UpdateMessageChannelInput} })
createEmailGroupChannel?: (CreateEmailGroupChannelOutputGenqlSelection & { __args: {input: CreateEmailGroupChannelInput} })
deleteEmailGroupChannel?: (MessageChannelGenqlSelection & { __args: {id: Scalars['UUID']} })
deleteConnectedAccount?: (ConnectedAccountPublicDTOGenqlSelection & { __args: {id: Scalars['UUID']} })
updateCalendarChannel?: (CalendarChannelGenqlSelection & { __args: {input: UpdateCalendarChannelInput} })
createChatThread?: AgentChatThreadGenqlSelection
sendChatMessage?: (SendChatMessageResultGenqlSelection & { __args: {threadId: Scalars['UUID'], text: Scalars['String'], messageId: Scalars['UUID'], browsingContext?: (Scalars['JSON'] | null), modelId?: (Scalars['String'] | null), fileAttachments?: (FileAttachmentInput[] | null)} })
@@ -5862,6 +5874,7 @@ export interface MutationGenqlSelection{
authorizeApp?: (AuthorizeAppGenqlSelection & { __args: {clientId: Scalars['String'], codeChallenge?: (Scalars['String'] | null), redirectUrl: Scalars['String'], state?: (Scalars['String'] | null), scope?: (Scalars['String'] | null)} })
renewToken?: (AuthTokensGenqlSelection & { __args: {appToken: Scalars['String']} })
generateApiKeyToken?: (ApiKeyTokenGenqlSelection & { __args: {apiKeyId: Scalars['UUID'], expiresAt: Scalars['String']} })
generatePlaygroundToken?: AuthTokenGenqlSelection
emailPasswordResetLink?: (EmailPasswordResetLinkGenqlSelection & { __args: {email: Scalars['String'], workspaceId?: (Scalars['UUID'] | null)} })
updatePasswordViaResetToken?: (InvalidatePasswordGenqlSelection & { __args: {passwordResetToken: Scalars['String'], newPassword: Scalars['String']} })
createApplicationRegistration?: (CreateApplicationRegistrationGenqlSelection & { __args: {input: CreateApplicationRegistrationInput} })
@@ -5902,9 +5915,6 @@ export interface MutationGenqlSelection{
updatePublicDomain?: (PublicDomainGenqlSelection & { __args: {domain: Scalars['String'], applicationId?: (Scalars['String'] | null)} })
deletePublicDomain?: { __args: {domain: Scalars['String']} }
checkPublicDomainValidRecords?: (DomainValidRecordsGenqlSelection & { __args: {domain: Scalars['String']} })
createEmailingDomain?: (EmailingDomainGenqlSelection & { __args: {domain: Scalars['String'], driver: EmailingDomainDriver} })
deleteEmailingDomain?: { __args: {id: Scalars['String']} }
verifyEmailingDomain?: (EmailingDomainGenqlSelection & { __args: {id: Scalars['String']} })
createOneAppToken?: (AppTokenGenqlSelection & { __args: {input: CreateOneAppTokenInput} })
/** @deprecated Use installApplication instead */
installMarketplaceApp?: { __args: {universalIdentifier: Scalars['String'], version?: (Scalars['String'] | null)} }
@@ -6082,6 +6092,8 @@ export interface UpdatePageLayoutWidgetWithIdInput {id: Scalars['UUID'],pageLayo
export interface GridPositionInput {row: Scalars['Float'],column: Scalars['Float'],rowSpan: Scalars['Float'],columnSpan: Scalars['Float']}
export interface SendEmailViaDomainInput {emailingDomainId: Scalars['String'],to: Scalars['String'][],cc?: (Scalars['String'][] | null),bcc?: (Scalars['String'][] | null),subject: Scalars['String'],text: Scalars['String'],html?: (Scalars['String'] | null),from: Scalars['String'],replyTo?: (Scalars['String'][] | null)}
export interface CreatePageLayoutWidgetInput {pageLayoutTabId: Scalars['UUID'],title: Scalars['String'],type: WidgetType,objectMetadataId?: (Scalars['UUID'] | null),gridPosition: GridPositionInput,position?: (Scalars['JSON'] | null),configuration: Scalars['JSON']}
export interface UpdatePageLayoutWidgetInput {pageLayoutTabId?: (Scalars['UUID'] | null),title?: (Scalars['String'] | null),type?: (WidgetType | null),objectMetadataId?: (Scalars['UUID'] | null),gridPosition?: (GridPositionInput | null),position?: (Scalars['JSON'] | null),configuration?: (Scalars['JSON'] | null),conditionalDisplay?: (Scalars['JSON'] | null),conditionalAvailabilityExpression?: (Scalars['String'] | null)}
@@ -6707,7 +6719,7 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const WidgetConfiguration_possibleTypes: string[] = ['AggregateChartConfiguration','StandaloneRichTextConfiguration','PieChartConfiguration','LineChartConfiguration','IframeConfiguration','GaugeChartConfiguration','BarChartConfiguration','CalendarConfiguration','FrontComponentConfiguration','EmailsConfiguration','EmailThreadConfiguration','FieldConfiguration','FieldRichTextConfiguration','FieldsConfiguration','FilesConfiguration','NotesConfiguration','TasksConfiguration','TimelineConfiguration','ViewConfiguration','RecordTableConfiguration','WorkflowConfiguration','WorkflowRunConfiguration','WorkflowVersionConfiguration']
const WidgetConfiguration_possibleTypes: string[] = ['AggregateChartConfiguration','StandaloneRichTextConfiguration','PieChartConfiguration','LineChartConfiguration','IframeConfiguration','BarChartConfiguration','CalendarConfiguration','FrontComponentConfiguration','EmailsConfiguration','EmailThreadConfiguration','FieldConfiguration','FieldRichTextConfiguration','FieldsConfiguration','FilesConfiguration','NotesConfiguration','TasksConfiguration','TimelineConfiguration','ViewConfiguration','RecordTableConfiguration','WorkflowConfiguration','WorkflowRunConfiguration','WorkflowVersionConfiguration']
export const isWidgetConfiguration = (obj?: { __typename?: any } | null): obj is WidgetConfiguration => {
if (!obj?.__typename) throw new Error('__typename is missing in "isWidgetConfiguration"')
return WidgetConfiguration_possibleTypes.includes(obj.__typename)
@@ -6755,14 +6767,6 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const GaugeChartConfiguration_possibleTypes: string[] = ['GaugeChartConfiguration']
export const isGaugeChartConfiguration = (obj?: { __typename?: any } | null): obj is GaugeChartConfiguration => {
if (!obj?.__typename) throw new Error('__typename is missing in "isGaugeChartConfiguration"')
return GaugeChartConfiguration_possibleTypes.includes(obj.__typename)
}
const BarChartConfiguration_possibleTypes: string[] = ['BarChartConfiguration']
export const isBarChartConfiguration = (obj?: { __typename?: any } | null): obj is BarChartConfiguration => {
if (!obj?.__typename) throw new Error('__typename is missing in "isBarChartConfiguration"')
@@ -6955,6 +6959,30 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const VerificationRecord_possibleTypes: string[] = ['VerificationRecord']
export const isVerificationRecord = (obj?: { __typename?: any } | null): obj is VerificationRecord => {
if (!obj?.__typename) throw new Error('__typename is missing in "isVerificationRecord"')
return VerificationRecord_possibleTypes.includes(obj.__typename)
}
const EmailingDomain_possibleTypes: string[] = ['EmailingDomain']
export const isEmailingDomain = (obj?: { __typename?: any } | null): obj is EmailingDomain => {
if (!obj?.__typename) throw new Error('__typename is missing in "isEmailingDomain"')
return EmailingDomain_possibleTypes.includes(obj.__typename)
}
const SendEmailViaDomainOutput_possibleTypes: string[] = ['SendEmailViaDomainOutput']
export const isSendEmailViaDomainOutput = (obj?: { __typename?: any } | null): obj is SendEmailViaDomainOutput => {
if (!obj?.__typename) throw new Error('__typename is missing in "isSendEmailViaDomainOutput"')
return SendEmailViaDomainOutput_possibleTypes.includes(obj.__typename)
}
const ApprovedAccessDomain_possibleTypes: string[] = ['ApprovedAccessDomain']
export const isApprovedAccessDomain = (obj?: { __typename?: any } | null): obj is ApprovedAccessDomain => {
if (!obj?.__typename) throw new Error('__typename is missing in "isApprovedAccessDomain"')
@@ -7859,22 +7887,6 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const VerificationRecord_possibleTypes: string[] = ['VerificationRecord']
export const isVerificationRecord = (obj?: { __typename?: any } | null): obj is VerificationRecord => {
if (!obj?.__typename) throw new Error('__typename is missing in "isVerificationRecord"')
return VerificationRecord_possibleTypes.includes(obj.__typename)
}
const EmailingDomain_possibleTypes: string[] = ['EmailingDomain']
export const isEmailingDomain = (obj?: { __typename?: any } | null): obj is EmailingDomain => {
if (!obj?.__typename) throw new Error('__typename is missing in "isEmailingDomain"')
return EmailingDomain_possibleTypes.includes(obj.__typename)
}
const AutocompleteResult_possibleTypes: string[] = ['AutocompleteResult']
export const isAutocompleteResult = (obj?: { __typename?: any } | null): obj is AutocompleteResult => {
if (!obj?.__typename) throw new Error('__typename is missing in "isAutocompleteResult"')
@@ -8163,6 +8175,14 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const WorkspaceAiStats_possibleTypes: string[] = ['WorkspaceAiStats']
export const isWorkspaceAiStats = (obj?: { __typename?: any } | null): obj is WorkspaceAiStats => {
if (!obj?.__typename) throw new Error('__typename is missing in "isWorkspaceAiStats"')
return WorkspaceAiStats_possibleTypes.includes(obj.__typename)
}
const CalendarChannel_possibleTypes: string[] = ['CalendarChannel']
export const isCalendarChannel = (obj?: { __typename?: any } | null): obj is CalendarChannel => {
if (!obj?.__typename) throw new Error('__typename is missing in "isCalendarChannel"')
@@ -8406,6 +8426,11 @@ export const enumCommandMenuItemAvailabilityType = {
FALLBACK: 'FALLBACK' as const
}
export const enumLogicFunctionExecutionMode = {
LIVE: 'LIVE' as const,
PREBUILT: 'PREBUILT' as const
}
export const enumFieldMetadataType = {
ACTOR: 'ACTOR' as const,
ADDRESS: 'ADDRESS' as const,
@@ -8560,7 +8585,6 @@ export const enumPageLayoutTabLayoutMode = {
export const enumWidgetConfigurationType = {
AGGREGATE_CHART: 'AGGREGATE_CHART' as const,
GAUGE_CHART: 'GAUGE_CHART' as const,
PIE_CHART: 'PIE_CHART' as const,
BAR_CHART: 'BAR_CHART' as const,
LINE_CHART: 'LINE_CHART' as const,
@@ -8627,7 +8651,8 @@ export const enumFieldDisplayMode = {
CARD: 'CARD' as const,
EDITOR: 'EDITOR' as const,
FIELD: 'FIELD' as const,
VIEW: 'VIEW' as const
VIEW: 'VIEW' as const,
TABLE: 'TABLE' as const
}
export const enumPageLayoutType = {
@@ -8637,6 +8662,17 @@ export const enumPageLayoutType = {
STANDALONE_PAGE: 'STANDALONE_PAGE' as const
}
export const enumEmailingDomainDriver = {
AWS_SES: 'AWS_SES' as const
}
export const enumEmailingDomainStatus = {
PENDING: 'PENDING' as const,
VERIFIED: 'VERIFIED' as const,
FAILED: 'FAILED' as const,
TEMPORARY_FAILURE: 'TEMPORARY_FAILURE' as const
}
export const enumBillingPlanKey = {
PRO: 'PRO' as const,
ENTERPRISE: 'ENTERPRISE' as const
@@ -8703,10 +8739,11 @@ export const enumFeatureFlagKey = {
IS_JSON_FILTER_ENABLED: 'IS_JSON_FILTER_ENABLED' as const,
IS_MARKETPLACE_SETTING_TAB_VISIBLE: 'IS_MARKETPLACE_SETTING_TAB_VISIBLE' as const,
IS_PUBLIC_DOMAIN_ENABLED: 'IS_PUBLIC_DOMAIN_ENABLED' as const,
IS_EMAILING_DOMAIN_ENABLED: 'IS_EMAILING_DOMAIN_ENABLED' as const,
IS_EMAIL_GROUP_ENABLED: 'IS_EMAIL_GROUP_ENABLED' as const,
IS_JUNCTION_RELATIONS_ENABLED: 'IS_JUNCTION_RELATIONS_ENABLED' as const,
IS_REST_METADATA_API_NEW_FORMAT_DIRECT: 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT' as const
IS_REST_METADATA_API_NEW_FORMAT_DIRECT: 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT' as const,
IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED: 'IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED' as const,
IS_SETTINGS_DISCOVERY_HERO_ENABLED: 'IS_SETTINGS_DISCOVERY_HERO_ENABLED' as const
}
export const enumIdentityProviderType = {
@@ -8750,17 +8787,6 @@ export const enumBillingEntitlementKey = {
AUDIT_LOGS: 'AUDIT_LOGS' as const
}
export const enumEmailingDomainDriver = {
AWS_SES: 'AWS_SES' as const
}
export const enumEmailingDomainStatus = {
PENDING: 'PENDING' as const,
VERIFIED: 'VERIFIED' as const,
FAILED: 'FAILED' as const,
TEMPORARY_FAILURE: 'TEMPORARY_FAILURE' as const
}
export const enumCalendarChannelSyncStatus = {
NOT_SYNCED: 'NOT_SYNCED' as const,
ONGOING: 'ONGOING' as const,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,50 @@
{
"name": "twenty",
"version": "0.1.0",
"description": "Create, operate, develop, publish, and query Twenty apps through Codex.",
"author": {
"name": "Twenty PBC",
"url": "https://twenty.com/"
},
"homepage": "https://docs.twenty.com/",
"repository": "https://github.com/twentyhq/twenty",
"license": "AGPL-3.0",
"keywords": [
"twenty",
"create-twenty-app",
"mcp",
"oauth",
"crm",
"apps",
"scaffolding",
"cli",
"twenty-sdk",
"publishing"
],
"skills": "./skills/",
"mcpServers": "./.mcp.json",
"interface": {
"displayName": "Twenty",
"shortDescription": "Create, develop, publish, and query apps",
"longDescription": "Scaffold, develop, deploy, and publish Twenty apps end-to-end. Five focused skills cover app creation, entity development (objects, fields, logic, layouts, front components), remote/sync/deploy operations, marketplace publishing assets, and workspace data retrieval via MCP. The bundled Twenty docs MCP server keeps guidance current, and the setup helper wires per-user workspace MCP with OAuth in one command.",
"developerName": "Twenty PBC",
"category": "Coding",
"capabilities": [
"Interactive",
"Read",
"Write"
],
"websiteURL": "https://twenty.com/",
"privacyPolicyURL": "https://twenty.com/privacy-policy",
"termsOfServiceURL": "https://twenty.com/terms",
"defaultPrompt": [
"Create and run a new Twenty app",
"Push a Twenty app to production",
"Develop a Twenty app feature"
],
"brandColor": "#000000",
"composerIcon": "./assets/twenty-logo.svg",
"logo": "./assets/twenty-logo.png",
"screenshots": []
}
}
+1
View File
@@ -0,0 +1 @@
.mcp.local.json
+7
View File
@@ -0,0 +1,7 @@
{
"mcpServers": {
"twenty-docs": {
"url": "https://docs.twenty.com/mcp"
}
}
}
+73
View File
@@ -0,0 +1,73 @@
# Twenty Codex Plugin — Agent Guidance
This file is the canonical entry point for any agent (Codex, ChatGPT Developer Mode, or compatible) running with the Twenty plugin loaded. It encodes the boundaries, conventions, and operating rules that apply across all five bundled skills.
Per-skill SKILL.md files own task-specific guidance. This file owns the cross-skill expectations.
## What This Plugin Does
The Twenty Codex plugin helps users build, operate, develop, publish, and query Twenty apps — modular extensions for the Twenty CRM platform. It bundles:
- **5 skills** for the canonical Twenty app workflows.
- **15 reference docs** under `references/` covering concepts, data model, UI, layouts, CLI, publishing, and MCP.
- **1 public MCP server** (`twenty-docs`) for searching official Twenty documentation.
- **1 setup helper** (`scripts/setup-mcp.sh`) for adding user-local workspace MCP endpoints.
A Twenty app is a standalone npm package that extends a running Twenty instance. It is not a standalone application. For the full mental model read `references/concepts/how-apps-work.md` before doing anything substantive.
## Skill Routing
Pick exactly one skill at a time based on the user's intent. Skills are deliberately disjoint.
| User intent | Skill |
|---|---|
| Scaffold a brand-new Twenty app | `create-app` |
| Add or modify objects, fields, logic, views, front components, workflows | `develop-app` |
| Manage remotes, sync, build, deploy, logs, troubleshoot, CI/CD | `manage-app` |
| Prepare README, marketplace metadata, logos, screenshots for publishing | `publish-app` |
| Connect to a workspace via MCP, retrieve records, format readable Markdown | `use-twenty-mcp` |
If the user's request straddles two skills, do the boundary task in the first skill and explicitly hand off to the second. Never silently combine.
## Durable Operating Rules
These rules apply in every skill. They exist because they have failed before.
1. **One-shot sync only.** Use `yarn twenty dev --once` to synchronize app changes. Never `yarn twenty dev` (watch mode). Watch mode leaks file handles and produces ambiguous failure output in agent sandboxes.
2. **Do not run broad validation unless it is requested.** After scaffolding (`create-twenty-app`) or after the CLI generates entities, prefer the bounded command that matches the task: `yarn twenty dev --once` for app sync, the package's unit-test script for unit tests, and `TWENTY_API_URL=http://localhost:2021 yarn test` for the full integration suite. Integration tests must target the isolated test instance on port `2021`, not the dev instance on port `2020`, unless the user explicitly asks otherwise.
3. **Use `yarn twenty dev:add` for new entities.** It generates correct file structure, UUIDs, SDK imports, and boilerplate. Do not hand-craft entity files unless modifying existing ones or the CLI does not support that entity type.
4. **Confirm destructive operations.** Deploys to production, uninstalls, production remote changes, and production syncs require explicit user confirmation before execution. Treat `--remote production` as user-visible.
5. **Never bundle workspace-specific MCP URLs.** Workspace MCP endpoints belong in the user's local `.mcp.json` and Codex MCP config. The plugin only ships the public `twenty-docs` MCP server. Use `scripts/setup-mcp.sh` to configure workspace MCP for a specific user.
6. **Never put credentials in source.** Bearer tokens, API keys, OAuth secrets, and workspace-specific URLs are user-local. `TWENTY_DEPLOY_API_KEY` and similar live in CI secret stores, never committed.
## Reference Doc Map
When a skill points at a reference, read only what the task needs:
- `concepts/how-apps-work.md` — foundational. Read at the start of any non-trivial task.
- `develop-app/app-structure.md` — file layout, entity creation, validation checklist.
- `develop-app/data-model.md` — objects, fields, relations, roles, permissions.
- `develop-app/front-components.md` — front component source, SDK imports, runtime verification.
- `develop-app/layout.md` — views, navigation, page layouts, front component placement.
- `develop-app/standalone-pages.md` — full-page custom UI through standalone page layouts.
- `develop-app/logic.md` — logic functions, skills, agents, post-install, connection providers.
- `develop-app/workflows.md` — workflows, manual triggers, draft/activate lifecycle.
- `develop-app/tests.md` — test organization (`*.spec.ts` in `__tests__/`).
- `design/front-component-ui.md` — Twenty UI defaults and visual design rules.
- `manage-app/cli-and-sync.md` — CLI command semantics, sync modes, build, deploy, logs, CI/CD.
- `publish-app/prepare-for-app-store.md` — README, marketplace metadata, logos, screenshots.
- `use-twenty-mcp/setup.md` — workspace MCP URL normalization and OAuth setup.
- `use-twenty-mcp/result-formatting.md` — record link building, date formatting, readable Markdown.
## How to Verify You're Following Best Practices
Run `yarn workspace twenty-codex-plugin validate` after any change to this plugin. Every box in `CHECKLIST.md` must be satisfied before a release.
## Boundaries
This file is for *agents using the plugin*. If you are *editing the plugin itself*, see `CONTRIBUTING.md`.
+63
View File
@@ -0,0 +1,63 @@
# Changelog
All notable changes to the Twenty Codex plugin are documented in this file.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
Entries reference the canonical skills (`create-app`, `develop-app`, `manage-app`, `publish-app`, `use-twenty-mcp`) and the validation script (`scripts/validate.js`).
## [0.1.0] - Unreleased
The first version of the Twenty Codex plugin.
### Added
- `AGENTS.md` at the plugin root: durable cross-skill operating rules, skill routing table, and reference-doc map.
- `CHANGELOG.md` (this file).
- `CHECKLIST.md`: best-practices compliance matrix mapping each official Codex plugin requirement to an automated check or a manual sign-off.
- `CONTRIBUTING.md`: maintainer guide for adding skills, adding references, bumping versions, updating screenshots, and running validation.
- `project.json`: Nx targets (`validate`, `test`, `setup:mcp`, `fmt`).
- `templates/marketplace.example.json`: copy-pasteable template for the user-local `.agents/plugins/marketplace.json` entry.
- `assets/screenshots/README.md`: capture brief for the three marketplace screenshots.
- `## When To Use` section in every SKILL.md with representative trigger phrases and explicit "do not use this skill for X" boundary callouts.
- New validators under `scripts/validators/`: `assertInterfaceFields`, `assertAssets`, `assertMarketplaceTemplate`, `assertSkillTriggerPhrases`.
- `scripts/__tests__/validate.spec.js`: 29 `node:test` cases (smoke + targeted negative cases) covering every validator.
- `.github/workflows/ci-codex-plugin.yaml`: CI workflow running `validate` and `test` on PRs touching the plugin.
### Changed
- `scripts/validate.js` refactored from a single 760-line file into a thin entry point plus focused modules under `scripts/validators/` (`lib`, `metadata`, `assets`, `skills`, `references`, `cross-doc-contracts`, `setup-helper`).
- `package.json`: exposed `test` script; added `AGENTS.md`, `CHANGELOG.md`, `CHECKLIST.md`, `CONTRIBUTING.md`, `templates` to `files`.
- `.codex-plugin/plugin.json`: rewrote `interface.longDescription` into 3 scannable sentences (was a single ~400-char sentence).
- `README.md`: restructured into What/Installation/Skills/MCP/Development sections; added skills-overview table; linked `CONTRIBUTING.md`, `CHECKLIST.md`, `CHANGELOG.md`.
- `scripts/validators/lib.js` `isAllowedDocumentationHost`: added `developers.openai.com`, `keepachangelog.com`, `semver.org` to the placeholder host allowlist so external documentation references in `CHECKLIST.md`, `CONTRIBUTING.md`, and `CHANGELOG.md` pass validation.
- `references/develop-app/tests.md` and `references/manage-app/cli-and-sync.md`: documented that integration tests must run against the isolated test instance (`yarn twenty docker:start --test`, port `2021`) instead of the dev instance, since the test harness installs and uninstalls the app on its target server.
- `skills/manage-app/SKILL.md`: added direct test-run routing so "run tests" requests load the tests reference and run full suites with `TWENTY_API_URL=http://localhost:2021`.
- `scripts/validators/cross-doc-contracts.js`: added a testing-guidance contract so validation fails if the port-2021 integration-test rule drifts out of the manage skill or references.
- `references/develop-app/app-structure.md`, `skills/develop-app/SKILL.md`, `references/develop-app/logic.md`, `references/develop-app/front-components.md`: made the one-export-per-file rule explicit — every helper, type, and client file exports exactly one thing, and multiple function exports in a single file are forbidden. The rule counts exports, not declarations: a local, non-exported type may live alongside the util, but an exported or reused type splits into `src/types/<name>.ts` separate from `src/utils/<name>.util.ts`.
### Plugin Structure
- `.codex-plugin/plugin.json` manifest with `interface` metadata (display name, descriptions, category, capabilities, branding, default prompts).
- `package.json` declaring the workspace package and listing shipped files.
- `.mcp.json` declaring only the public `twenty-docs` MCP server at `https://docs.twenty.com/mcp`.
- `assets/twenty-logo.png` and `assets/twenty-logo.svg` for marketplace branding.
### Skills (`skills/`)
- `create-app` — scaffold a new Twenty app via `create-twenty-app`, with prompts for the Twenty instance URL and Docker vs existing-instance choice.
- `develop-app` — add or modify Twenty app entities (objects, fields, logic functions, roles, views, layouts, skills, agents, connection providers, front components); enforces `yarn twenty dev:add` for entity generation and one-shot `yarn twenty dev --once` sync.
- `manage-app` — remotes, sync, build, deploy, logs, function execution, uninstall, and CI/CD for existing apps; requires explicit confirmation for production-affecting operations.
- `publish-app` — README/About copy, package metadata, `defineApplication` marketplace fields, logos, screenshots, npm/marketplace publication.
- `use-twenty-mcp` — workspace MCP connection, record retrieval, and readable Markdown output with linked record names.
- Per-skill `agents/openai.yaml` with `display_name`, `short_description` (≤64 chars), and `default_prompt` that mentions `$<skill-name>`.
### References (`references/`)
- `concepts/how-apps-work.md` — foundational SDK packages, remotes, sync lifecycle, front component rendering, app file structure, key concepts (linked from every skill).
- `develop-app/app-structure.md`, `data-model.md`, `front-components.md`, `layout.md`, `standalone-pages.md`, `logic.md`, `workflows.md`, `tests.md` — entity creation, file structure, validation checklist, full-page UI patterns, runtime verification.
- `design/front-component-ui.md` — visual design rules, Twenty UI defaults, design tokens, component selection.
- `manage-app/cli-and-sync.md` — CLI command semantics, sync modes, build, deploy, logs, CI/CD; explicit ban on watch mode (`yarn twenty dev` without `--once`) for agent use.
- `operations/command-execution.md` — external command execution patterns.
- `publish-app/prepare-for-app-store.md` — npm/marketplace publication checklist.
- `use-twenty-mcp/setup.md` — workspace MCP URL normalization and OAuth setup.
- `use-twenty-mcp/result-formatting.md` — record link building, date formatting, readable Markdown contract.
### Tooling
- `scripts/setup-mcp.sh` — helper to register a user-local workspace MCP endpoint with Codex; normalizes workspace URLs (adds `https://`, `/mcp` suffix), derives sensible MCP server names, supports localhost and custom domains, integrates with `codex mcp add` and OAuth.
- `scripts/validate.js` — single-file validation script enforcing version sync, no bundled workspace MCP, no secrets/non-placeholder URLs, canonical skill names, required SKILL.md + `agents/openai.yaml` shape, required reference files, cross-doc contracts (MCP formatting, front-component guidance, CLI guidance split, foundational concepts linkage), and `setup-mcp.sh` syntax + URL-normalization correctness.
+112
View File
@@ -0,0 +1,112 @@
# Twenty Codex Plugin — Best Practices Compliance Checklist
This file is the authoritative compliance matrix. Every row maps an official requirement (Codex build guide, Codex best-practices guide, or OpenAI reference implementation) to either an **automated** check (a `scripts/validate.js` assertion function) or a **manual** sign-off.
A release is shippable when:
1. `yarn workspace twenty-codex-plugin validate` exits 0 (all `[automated]` rows green).
2. Every `[manual]` row has a current sign-off date.
## Sources of Truth
- **Codex Plugin Build Guide** — https://developers.openai.com/codex/plugins/build
- **Codex Best Practices** — https://developers.openai.com/codex/learn/best-practices
- **Reference Implementation** — https://github.com/openai/codex-plugin-cc
## Plugin Manifest (`.codex-plugin/plugin.json`)
| # | Requirement | Source | Check |
|---|---|---|---|
| M1 | `name` present, kebab-case, lowercase | Build guide §Naming | [automated] `assertJsonMetadata` |
| M2 | `version` follows SemVer and matches `package.json` | Build guide §Manifest | [automated] `assertJsonMetadata` |
| M3 | `description` present and concise | Build guide §Required fields | [automated] `assertInterfaceFields` (planned) |
| M4 | `mcpServers` points at `./.mcp.json` | Build guide §MCP | [automated] `assertJsonMetadata` |
| M5 | `skills` points at `./skills/` | Build guide §Skills | [automated] `assertJsonMetadata` |
| M6 | `interface.displayName` present and non-empty | Build guide §Interface | [automated] `assertInterfaceFields` (planned) |
| M7 | `interface.shortDescription` ≤ 64 chars | Build guide §Marketplace | [automated] `assertInterfaceFields` (planned) |
| M8 | `interface.longDescription` present, multi-sentence | Build guide §Marketplace | [automated] `assertInterfaceFields` (planned) |
| M9 | `interface.category` matches marketplace enum (`Coding`) | Build guide §Marketplace | [automated] `assertInterfaceFields` (planned) |
| M10 | `interface.capabilities` non-empty subset of `Interactive,Read,Write` | Build guide §Capabilities | [automated] `assertInterfaceFields` (planned) |
| M11 | `interface.websiteURL`, `privacyPolicyURL`, `termsOfServiceURL` present | Build guide §Publisher links | [automated] `assertInterfaceFields` (planned) |
| M12 | `interface.brandColor` matches `#RRGGBB` and reflects Twenty brand | Build guide §Marketplace | [automated] `assertInterfaceFields` (planned), [manual] brand alignment |
| M13 | `interface.defaultPrompt` is a non-empty array of strings | Build guide §Defaults | [automated] `assertInterfaceFields` (planned) |
| M14 | All manifest paths start with `./` and stay within plugin root | Build guide §Paths | [automated] `assertJsonMetadata` |
## Assets (`assets/`)
| # | Requirement | Source | Check |
|---|---|---|---|
| A1 | `interface.logo` exists and is PNG | Build guide §Assets | [automated] `assertAssets` (planned) |
| A2 | Logo PNG is ≥ 256×256 | Build guide §Assets | [automated] `assertAssets` (planned, PNG IHDR parse) |
| A3 | `interface.composerIcon` exists and is valid SVG or PNG | Build guide §Assets | [automated] `assertAssets` (planned) |
| A4 | `interface.screenshots` has ≥ 1 entry | Build guide §Assets | [automated] `assertAssets` (planned) |
| A5 | Every screenshot path exists and is PNG | Build guide §Assets | [automated] `assertAssets` (planned) |
| A6 | Screenshots show the plugin in action (not placeholder mocks) | Build guide §Marketplace | [manual] reviewer sign-off |
## Skills (`skills/<name>/SKILL.md`)
| # | Requirement | Source | Check |
|---|---|---|---|
| S1 | Canonical five skills present | Internal convention | [automated] `assertSkills` |
| S2 | No legacy skill names anywhere | Internal convention | [automated] `assertNoLegacySkillReferences` |
| S3 | Each `SKILL.md` has YAML frontmatter with `name` + `description` only | Build guide §Skills | [automated] `assertSkills` |
| S4 | Frontmatter `name` matches directory | Build guide §Skills | [automated] `assertSkills` |
| S5 | Each skill has `agents/openai.yaml` with `display_name`, `short_description` (≤64), `default_prompt` (mentions `$<skill>`) | OpenAI Agent format | [automated] `assertSkills` |
| S6 | Each skill is scoped to a single job with clear boundaries | Best practices §Skill scope | [manual] cross-skill audit |
| S7 | Each skill includes a `## When to use` trigger-phrase section | Best practices §Descriptions | [automated] `assertSkillTriggerPhrases` (planned) |
| S8 | Descriptions are disjoint across skills (no routing collision) | Best practices §Descriptions | [manual] cross-skill audit |
## References (`references/`)
| # | Requirement | Source | Check |
|---|---|---|---|
| R1 | All required reference files exist | Internal convention | [automated] `assertReferences` |
| R2 | `use-twenty-mcp` formatting contract intact across SKILL.md and result-formatting.md | Internal convention | [automated] `assertTwentyMcpFormattingContract` |
| R3 | Front component guidance correctly split across develop-app skill, layout.md, front-components.md, standalone-pages.md, app-structure.md, front-component-ui.md | Internal convention | [automated] `assertFrontComponentGuidance` |
| R4 | CLI guidance correctly split between develop-app and manage-app | Internal convention | [automated] `assertCliGuidanceSplit` |
| R5 | `concepts/how-apps-work.md` is foundational and linked from every skill | Internal convention | [automated] `assertHowAppsWork` |
## MCP (`.mcp.json` and `scripts/setup-mcp.sh`)
| # | Requirement | Source | Check |
|---|---|---|---|
| MCP1 | `.mcp.json` declares only the public `twenty-docs` server | Internal convention | [automated] `assertJsonMetadata` |
| MCP2 | No workspace-specific MCP configs bundled anywhere | Internal convention | [automated] `assertNoBundledMcpConfig` |
| MCP3 | No bearer tokens or API keys anywhere in the package | Build guide §Security | [automated] `assertNoBundledMcpConfig` |
| MCP4 | No non-placeholder URLs in any file | Internal convention | [automated] `assertNoBundledMcpConfig` |
| MCP5 | `scripts/setup-mcp.sh` passes `bash -n` syntax check | Internal convention | [automated] `assertSetupHelper` |
| MCP6 | `setup-mcp.sh` correctly normalizes representative URLs | Internal convention | [automated] `assertSetupHelper` |
## Marketplace & Distribution
| # | Requirement | Source | Check |
|---|---|---|---|
| D1 | `templates/marketplace.example.json` matches plugin name/version | Internal convention | [automated] `assertMarketplaceTemplate` (planned) |
| D2 | Optional repo-level `.agents/plugins/marketplace.json` points at `./packages/twenty-codex-plugin` | Build guide §Distribution | [automated] `assertJsonMetadata` |
| D3 | `CHANGELOG.md` updated for every version bump | Reference impl | [manual] release reviewer sign-off |
## Process
| # | Requirement | Source | Check |
|---|---|---|---|
| P1 | `validate.js` runs in CI on every PR touching the plugin | Reference impl | [manual] confirm `.github/workflows/ci-codex-plugin.yml` is active |
| P2 | `validate.js` has its own unit tests | Internal convention | [manual] confirm `scripts/__tests__/` exists and runs |
| P3 | Version bump procedure documented | Internal convention | [manual] confirm `CONTRIBUTING.md` covers it |
## Manual Sign-off Log
When a manual row above is verified, add a line here.
| Date | Row(s) | Reviewer | Notes |
|---|---|---|---|
| 2026-05-28 | S6, S8 | Codex plugin compliance pass | Skill descriptions reviewed side-by-side; "When To Use" sections added to all five SKILL.md files with explicit "do not use this skill for X" boundary callouts referencing the four siblings. Routing surface is disjoint. |
| 2026-05-28 | M12 | Codex plugin compliance pass | `brandColor: #000000` matches the actual logo background in `assets/twenty-logo.svg`. |
## Deferred Items
Tracked for a follow-up PR; not blocking the current release.
| Item | Reason for deferral |
|---|---|
| Real marketplace screenshots (A4, A5, A6) | Requires real captures against a demo workspace. `assets/screenshots/README.md` documents what's needed; `plugin.json` `screenshots` stays `[]` until the PNGs land so `assertAssets` does not regress. |
| Splitting `standalone-pages.md` / `front-component-ui.md` | The cross-doc contracts assert dozens of exact fragments per file. The mechanical risk of fragment drift outweighs the readability gain at current size (~450 lines each). Re-evaluate when either exceeds 600 lines. |
| `hooks/hooks.json` SessionStart hook | The Codex hook schema for plugin-bundled hooks needs deeper validation; a misconfigured `SessionStart` hook would fire on every Codex session. Add only when the format is verified against a working reference implementation. |
@@ -0,0 +1,38 @@
# Contributing
For maintainers of the plugin itself. Agents *using* the plugin → see [`AGENTS.md`](./AGENTS.md).
## Gate
```bash
npx nx run twenty-codex-plugin:validate
npx nx run twenty-codex-plugin:test
```
Both must pass before merge. No new runtime deps in `scripts/` — validators use Node built-ins only.
## Adding a skill
1. Create `skills/<name>/SKILL.md` (frontmatter: `name`, `description` only) and `skills/<name>/agents/openai.yaml` (`display_name`, `short_description` ≤ 64, `default_prompt` mentioning `$<name>`).
2. Add a `## When To Use` section with 46 user-language triggers and "do not use this skill for X" callouts referencing siblings.
3. Add the name to `EXPECTED_CANONICAL_SKILLS` in `scripts/validators/skills.js`.
## Adding a reference
1. Place under `references/<area>/<name>.md`. Link from the SKILL.md that needs it.
2. Add the path to `REQUIRED_REFERENCES` in `scripts/validators/references.js`.
3. If the file participates in a cross-doc contract, update `scripts/validators/cross-doc-contracts.js` in the same commit.
## Bumping the version
Move all three together: `package.json`, `.codex-plugin/plugin.json`, `templates/marketplace.example.json`. Then move `[Unreleased]` in `CHANGELOG.md` under a new `[X.Y.Z] - YYYY-MM-DD` heading.
SemVer: **patch** for copy/validation fixes, **minor** for new references/rules/skill sections, **major** for renaming a canonical skill or breaking the frontmatter/agents.yaml shape.
## Editing validators
New assertion = new function in the right `scripts/validators/*.js` module + a call from `scripts/validate.js` + at least one passing and one failing fixture in `scripts/__tests__/validate.spec.js`.
## PRs
One concern per PR. Title prefix: `feat|fix|docs|chore(codex-plugin):`. Mention the [`CHECKLIST.md`](./CHECKLIST.md) rows touched.
+114
View File
@@ -0,0 +1,114 @@
# Twenty Codex Plugin
Official Codex plugin for building, deploying, and querying Twenty apps. Bundles five focused skills, the public Twenty documentation MCP server, and a one-command workspace MCP setup helper.
This package is the source of the plugin published to the Codex marketplace.
## What This Plugin Does
The plugin teaches Codex how to work with the Twenty CRM platform. After installation, Codex can:
- Scaffold a new Twenty app with `create-twenty-app`.
- Add or modify app entities (objects, fields, logic functions, layouts, front components, workflows).
- Manage remotes, sync changes, build, deploy, view logs, and configure CI/CD.
- Prepare README, marketplace metadata, logos, and screenshots for npm/marketplace publication.
- Connect to a Twenty workspace via MCP and present records as readable Markdown with linked record names.
## Installation
### From the Codex Marketplace
Search for "Twenty" in the Codex plugin directory and install.
### Locally for Development
Copy the marketplace template to a local marketplace config:
```bash
cp packages/twenty-codex-plugin/templates/marketplace.example.json .agents/plugins/marketplace.json
```
Then enable it in Codex via the plugin manager. See [`templates/marketplace.example.json`](./templates/marketplace.example.json) for the exact entry shape.
## Skills
| Skill | Use it for |
|---|---|
| [`create-app`](./skills/create-app/SKILL.md) | Scaffold a new Twenty app with `create-twenty-app`. |
| [`develop-app`](./skills/develop-app/SKILL.md) | Add or modify objects, fields, logic functions, layouts, front components, workflows. |
| [`manage-app`](./skills/manage-app/SKILL.md) | Manage remotes, sync, build, deploy, logs, troubleshooting, CI/CD. |
| [`publish-app`](./skills/publish-app/SKILL.md) | Prepare README, marketplace metadata, logos, screenshots, public assets. |
| [`use-twenty-mcp`](./skills/use-twenty-mcp/SKILL.md) | Configure Twenty MCP and retrieve workspace records as readable Markdown. |
Cross-skill operating rules are in [`AGENTS.md`](./AGENTS.md). Reference docs are under [`references/`](./references/).
## MCP Setup
The plugin works in two layers:
- The bundled `twenty-docs` MCP server works immediately and lets Codex search public Twenty documentation.
- Workspace data access is user-specific. Each user adds their own Twenty workspace MCP endpoint to their private Codex MCP config using the helper below.
**Do not** add workspace-specific MCP URLs to this package. They are user-local and belong only in the user's machine-local Codex MCP configuration.
### Quick MCP Setup
```bash
bash packages/twenty-codex-plugin/scripts/setup-mcp.sh myworkspace.twenty.com
```
The helper names the server after the workspace host (`twenty-myworkspace`, `twenty-acme-example`, etc.). Codex may open OAuth automatically after the server is added; if it does not, run `codex mcp login <server-name>`. Use `--force-login` only for terminal-only setup.
Equivalent manual CLI setup:
```bash
codex mcp add twenty-myworkspace --url https://myworkspace.twenty.com/mcp
codex mcp login twenty-myworkspace
```
Supported workspace forms:
```text
myworkspace.twenty.com -> https://myworkspace.twenty.com/mcp name: twenty-myworkspace
acme.example.com -> https://acme.example.com/mcp name: twenty-acme-example
myworkspace.customdomain.com -> https://myworkspace.customdomain.com/mcp name: twenty-myworkspace-customdomain
myworkspace.localhost:3001 -> http://myworkspace.localhost:3001/mcp name: twenty-myworkspace-localhost-3001
```
### App Declaration
Codex app declarations require a ChatGPT-created app or connector id. The plugin can reference that id, but it cannot create one from an MCP URL by itself.
For that reason, this package does not ship a default `.app.json`. A bundled app declaration would either point to the wrong workspace or expose an app id that is not valid for each user's ChatGPT Developer Mode setup. Keep app declarations local until there is an official shared Twenty connector id.
After creating the Twenty app in ChatGPT Developer Mode with your workspace MCP URL, add `packages/twenty-codex-plugin/.app.json`:
```json
{
"apps": {
"twenty": {
"id": "asdk_app_OR_connector_ID_FROM_CHATGPT"
}
}
}
```
Then add `"apps": "./.app.json"` to `packages/twenty-codex-plugin/.codex-plugin/plugin.json` and include `.app.json` in this package's `files` array.
## Development
Want to improve the plugin itself? See [`CONTRIBUTING.md`](./CONTRIBUTING.md) for skill authoring, reference editing, validation, and the release process.
### Common Commands
```bash
# Validate the plugin
npx nx run twenty-codex-plugin:validate
# Run validator unit tests
npx nx run twenty-codex-plugin:test
```
### Compliance
Best-practices compliance is tracked in [`CHECKLIST.md`](./CHECKLIST.md) — every official Codex requirement maps to either an automated `validate.js` assertion or a manual sign-off. Changes are recorded in [`CHANGELOG.md`](./CHANGELOG.md).
@@ -0,0 +1,29 @@
# Marketplace Screenshots
The Codex marketplace listing for the Twenty plugin requires ≥ 1 screenshot showing the plugin in action (Build guide §Assets).
## What to Capture
Three screenshots, all PNG, at marketplace resolution (preferably 1600×1000 or higher, 16:10):
1. **`01-create-app.png`** — `create-app` mid-flow: user prompt asking to scaffold a new Twenty app, the agent asking for the instance URL, and the `create-twenty-app` output visible.
2. **`02-develop-and-sync.png`** — `develop-app` adding an entity through `yarn twenty dev:add`, followed by `yarn twenty dev --once` syncing successfully.
3. **`03-use-twenty-mcp.png`** — `use-twenty-mcp` retrieving a workspace records table where the record-name column is rendered as a linked Markdown table.
## After Adding the Files
1. Drop the three PNGs into this directory using the exact filenames above.
2. Update `.codex-plugin/plugin.json` `interface.screenshots`:
```json
"screenshots": [
"./assets/screenshots/01-create-app.png",
"./assets/screenshots/02-develop-and-sync.png",
"./assets/screenshots/03-use-twenty-mcp.png"
]
```
3. Run `yarn workspace twenty-codex-plugin validate` — the planned `assertAssets` check confirms every referenced screenshot exists and is PNG.
4. Tick rows A4, A5, A6 in `CHECKLIST.md` with the reviewer's date.
## What Not to Include
No customer data, real workspace URLs, real API keys, or unreleased confidential content. Use the bundled local Docker instance or a dedicated demo workspace for captures.
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

@@ -0,0 +1,5 @@
<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="96" height="96" rx="11.293" fill="#000000"/>
<path d="M19.25 35.746C19.25 30.5 23.508 26.246 28.75 26.246H47.039C47.309 26.246 47.555 26.406 47.668 26.652C47.781 26.902 47.73 27.191 47.547 27.395L43.539 31.75C42.84 32.508 41.859 32.945 40.828 32.945H28.801C27.227 32.945 25.949 34.223 25.949 35.797V42.98C25.949 43.906 25.199 44.652 24.273 44.652H20.93C20.004 44.652 19.258 43.906 19.258 42.98V35.746Z" fill="white"/>
<path d="M76.152 60.254C76.152 65.5 71.895 69.754 66.648 69.754H58.879C53.633 69.754 49.375 65.5 49.375 60.254V46.652C49.375 45.727 49.723 44.836 50.352 44.152L54.883 39.234C55.074 39.027 55.371 38.957 55.637 39.055C55.898 39.164 56.074 39.41 56.074 39.691V60.211C56.074 61.785 57.352 63.063 58.926 63.063H66.605C68.18 63.063 69.457 61.785 69.457 60.211V35.797C69.457 34.223 68.18 32.945 66.605 32.945H57.676C56.652 32.945 55.68 33.375 54.98 34.121L28.348 63.063H44.352C45.273 63.063 46.023 63.813 46.023 64.738V68.082C46.023 69.008 45.273 69.754 44.352 69.754H22.785C20.832 69.754 19.242 68.168 19.242 66.211V64.441C19.242 63.551 19.574 62.695 20.18 62.039L50.039 29.605C52.016 27.457 54.789 26.246 57.707 26.246H66.641C71.887 26.246 76.145 30.5 76.145 35.746V60.254Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+31
View File
@@ -0,0 +1,31 @@
{
"name": "twenty-codex-plugin",
"version": "0.1.0",
"description": "Codex plugin for creating, managing, developing, publishing, and querying Twenty apps through Codex.",
"private": true,
"license": "AGPL-3.0",
"scripts": {
"setup:mcp": "bash ./scripts/setup-mcp.sh",
"validate": "node ./scripts/validate.js",
"test": "node --test ./scripts/__tests__/*.spec.js"
},
"files": [
".codex-plugin",
".mcp.json",
"AGENTS.md",
"CHANGELOG.md",
"CHECKLIST.md",
"CONTRIBUTING.md",
"README.md",
"assets",
"references",
"scripts",
"skills",
"templates"
],
"engines": {
"node": "^24.5.0",
"npm": "please-use-yarn",
"yarn": ">=4.0.2"
}
}
+59
View File
@@ -0,0 +1,59 @@
{
"name": "twenty-codex-plugin",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "packages/twenty-codex-plugin",
"projectType": "library",
"tags": ["scope:codex-plugin"],
"targets": {
"validate": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "{projectRoot}",
"command": "node ./scripts/validate.js"
},
"inputs": [
"{projectRoot}/.codex-plugin/**/*",
"{projectRoot}/.mcp.json",
"{projectRoot}/assets/**/*",
"{projectRoot}/package.json",
"{projectRoot}/references/**/*",
"{projectRoot}/scripts/**/*",
"{projectRoot}/skills/**/*",
"{projectRoot}/templates/**/*"
]
},
"test": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "{projectRoot}",
"command": "node --test ./scripts/__tests__/*.spec.js"
},
"inputs": [
"{projectRoot}/scripts/**/*"
]
},
"setup:mcp": {
"executor": "nx:run-commands",
"options": {
"cwd": "{projectRoot}",
"command": "bash ./scripts/setup-mcp.sh"
}
},
"fmt": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "{projectRoot}",
"command": "prettier . --check --cache --cache-location ../../.cache/prettier/{projectRoot} --cache-strategy metadata"
},
"configurations": {
"ci": {},
"fix": {
"command": "prettier . --write --cache --cache-location ../../.cache/prettier/{projectRoot} --cache-strategy metadata"
}
}
}
}
}
@@ -0,0 +1,110 @@
# How Twenty Apps Work
Use this reference to understand the Twenty application development model before creating, developing, or deploying an app. Other references cover specific workflows; this one explains the architecture behind them.
## What Is A Twenty App
A Twenty app is a standalone npm package that extends a Twenty instance. It is not part of the Twenty server codebase. It lives in its own directory with its own `package.json`, dependencies, and source tree. When installed on a Twenty instance, it adds objects, fields, views, page layouts, front components, logic functions, skills, agents, and more to that workspace.
The app does not run as a separate server. It is built, published, and installed into a running Twenty instance. The instance loads the app's entities into its schema and renders the app's front components inside the workspace UI.
## SDK Packages
A Twenty app depends on two SDK packages:
| Package | Purpose | Used in |
| --- | --- | --- |
| `twenty-sdk` | Define app entities and access front component runtime APIs | Entity definitions (`twenty-sdk/define`), front component hooks and host APIs (`twenty-sdk/front-component`), Twenty UI components (`twenty-sdk/ui`) |
| `twenty-client-sdk` | Access workspace data from front components | Core object queries (`twenty-client-sdk/core`), metadata queries (`twenty-client-sdk/metadata`) |
`twenty-sdk/define` provides the registration functions: `defineApplication`, `defineObject`, `defineField`, `defineView`, `definePageLayout`, `defineFrontComponent`, `defineNavigationMenuItem`, `defineLogicFunction`, `defineRole`, and others. Every app entity is declared through one of these functions.
`twenty-sdk/front-component` provides runtime APIs available inside front components: `navigate`, `enqueueSnackbar`, `openSidePanelPage`, `useSelectedRecordIds`, `getApplicationVariable`, and others.
`twenty-sdk/ui` re-exports Twenty UI components (`Button`, `Chip`, `Tag`, `Status`, `H2Title`, `ThemeProvider`, icons, `themeCssVariables`). Always import from `twenty-sdk/ui`, not from `twenty-ui` directly, so build aliases and runtime packaging stay aligned.
`twenty-client-sdk/core` provides `CoreApiClient` for querying and mutating workspace records (companies, people, custom objects). `twenty-client-sdk/metadata` provides access to workspace metadata (object definitions, field definitions).
## Twenty Instances And Remotes
A Twenty instance is a running Twenty server — either self-hosted or managed cloud. Each instance hosts one or more workspaces. Apps are installed per workspace.
A "remote" is the local CLI's connection to a Twenty instance. It stores the server URL and an API key in `~/.twenty/config.json`. You can configure multiple remotes (e.g. `local`, `staging`, `production`) and switch between them.
The API key authenticates the developer against the target workspace. It determines which workspace the app syncs to, deploys to, and reads data from during development.
## Local Development Environment
An app needs a running Twenty instance to develop against. There are two ways to connect:
1. **Existing instance via OAuth** (default): `create-twenty-app --url <twenty-instance-url>` authenticates via OAuth on the provided Twenty server. The scaffolder opens a browser for the OAuth flow, then stores the credentials as a remote in `~/.twenty/config.json`. This is best when the developer already has a workspace with data to develop against.
2. **Local instance with Docker** (fallback): `create-twenty-app` (no `--url`) starts a disposable local Twenty server on `http://localhost:2020` through Docker. The scaffolder authenticates with the local server's development API key and creates a `local` remote automatically. Use this when the developer has no Twenty instance available and wants to start fresh without affecting a shared workspace.
In both cases, the result is a remote stored in `~/.twenty/config.json` that the CLI uses for all subsequent sync, deploy, and data access commands.
## App Lifecycle
The development loop is:
```
create → develop → sync → validate → repeat
```
1. **Create**: `npx create-twenty-app@latest <app-name>` generates the project, installs dependencies, starts a local Twenty server, and runs an initial sync. The scaffolder handles everything in one command — do not run `yarn twenty dev --once` after scaffolding because the sync already happened.
2. **Develop**: Add or modify entities in `src/`. Objects go in `src/objects/`, front components in `src/front-components/`, logic functions in `src/logic-functions/`, page layouts in `src/page-layouts/`, and so on. Each entity file exports a `define*` call.
3. **Sync**: `yarn twenty dev --once` builds, deploys, and installs the app on the active remote in one step. The Twenty instance updates its schema, registers new objects and fields, mounts front components, and activates logic functions. This is the primary way to get changes onto a Twenty instance during development. Sync after every meaningful change.
4. **Validate**: `yarn twenty dev:typecheck` checks generated types. `yarn lint` checks local lint rules. Open the workspace in a browser to verify front components render and logic functions execute.
## Sharing An App
Publishing is separate from the development loop. It is used to share an app with other Twenty instances or the public marketplace:
- `yarn twenty app:publish` publishes to npm for the public marketplace.
- `yarn twenty app:publish --private --remote <name>` publishes privately to a specific Twenty instance's registry.
Each publish requires a strictly higher semver version in `package.json`. See the `publish-app` skill and `prepare-for-app-store.md` for marketplace metadata and listing guidance.
## Front Component Rendering
Front components do not run as a separate frontend application. The Twenty workspace renders them inside an isolated Remote DOM container. The SDK mounts the component, provides a `ThemeProvider`, and proxies host APIs (navigation, snackbars, side panels) through `twenty-sdk/front-component`.
This means:
- Front components cannot access `document` or `window` globals directly.
- They cannot use React portals or third-party libraries that manipulate the DOM outside their tree.
- They import UI primitives from `twenty-sdk/ui`, not from `twenty-ui` or external component libraries.
- They fetch workspace data through `twenty-client-sdk/core`, not through direct API calls.
## App File Structure
A typical Twenty app after scaffolding:
```
my-app/
package.json # App metadata, version, dependencies
src/
application-config.ts # defineApplication() — app identity and marketplace metadata
constants/
universal-identifiers.ts # Stable UUIDs for all entities
objects/ # defineObject, defineField, defineRelation
views/ # defineView
roles/ # defineRole
front-components/ # defineFrontComponent (.tsx)
page-layouts/ # definePageLayout
navigation-menu-items/ # defineNavigationMenuItem
logic-functions/ # defineLogicFunction
public/ # Static assets (logos, screenshots, images)
.github/workflows/ # CI/CD automation (optional)
```
`application-config.ts` is the app's entry point. It calls `defineApplication()` with the app's universal identifier, display name, description, default role, and marketplace metadata. Every entity references stable UUIDs from `constants/universal-identifiers.ts`.
## Key Concepts
- **Universal identifiers**: Stable UUIDs assigned to every entity. They survive renames, version bumps, and resyncs. Never change a universal identifier after first sync.
- **Remotes**: Named connections to Twenty instances. Stored in `~/.twenty/config.json`. Switch with `yarn twenty remote:use <name>`.
- **Sync**: `yarn twenty dev --once` builds, deploys, and installs the app on the active remote in one step. This is the standard way to get code changes onto a Twenty instance.
- **Publish**: `yarn twenty app:publish` packages the app for distribution to other instances or the marketplace. Requires a strictly higher semver version than the previously published version.
@@ -0,0 +1,451 @@
# When to Use
Use this when the user wants to design or polish a Twenty front component UI.
Examples:
- Make a front component look better.
- Create a clean layout for a front component.
- Improve loading, empty, error, or disabled states.
- Make a front component responsive and easier to scan.
# Boundaries
Do not scaffold a new app here. Use `create-app` first when the app does not exist.
Do not use this reference for source files, registration, runtime imports, data access, CLI commands, or browser verification.
# Design Rules
Design front components as compact CRM product UI, not marketing pages. The goal is a surface that scans quickly, feels native inside Twenty, and keeps actions predictable.
Use Figma measurements as evidence for the current Twenty rhythm, not as implementation literals. Source code should use Twenty primitives, component props, and `themeCssVariables` instead of hardcoded visual values.
## Twenty UI Defaults
Prefer Twenty UI primitives for CRM-native front component UI:
- Use `H2Title` or `H3Title` for compact section headings.
- Use `Callout` with a matching icon for loading, empty, error, and blocked states.
- Use `Button` for primary and secondary actions.
- Use `Tag`, `Status`, `Chip`, `Label`, and `Avatar` for metadata, state, people, and small summaries.
- Use `themeCssVariables` for spacing, colors, border radius, typography, borders, icon sizing, shadows, and backgrounds.
Use local inline styles for layout containers and custom data displays, but keep them aligned with Twenty tokens. Use `front-components.md` for exact imports and runtime rules.
Use Twenty UI icons before custom SVG. Use `themeCssVariables.icon.size.md` for default row and action icons, `themeCssVariables.icon.size.sm` for quiet chevrons, and `themeCssVariables.spacing[6]` for icon-only action targets.
## Token-First Implementation
When turning a Figma observation into code, translate the visual target into a token:
- Typography uses `themeCssVariables.font.size.*`, `themeCssVariables.font.weight.*`, and `themeCssVariables.text.lineHeight.*`.
- Spacing uses `themeCssVariables.spacing[...]` and `themeCssVariables.betweenSiblingsGap`.
- Radius uses `themeCssVariables.border.radius.sm` for compact controls and `themeCssVariables.border.radius.md` for cards or panels.
- Colors use `themeCssVariables.font.color.*`, `themeCssVariables.background.*`, `themeCssVariables.border.color.*`, or component props.
- Icons use `themeCssVariables.icon.size.*` or native Twenty UI icon sizing.
- Borders use `themeCssVariables.border.color.*`; avoid raw border colors.
- Shadows use `themeCssVariables.boxShadow.*`; avoid custom shadow stacks unless mirroring an existing Twenty overlay exactly.
Do not create source constants named after raw visual measurements. Use semantic names that point to tokens:
```tsx
const compactControlHeight = themeCssVariables.spacing[6];
const denseRowHeight = themeCssVariables.spacing[8];
const panelRadius = themeCssVariables.border.radius.md;
```
Avoid source constants named after raw measurements, such as `rowHeight` or `cardRadius`, when they hide a token choice. Prefer semantic names like `denseRowHeight` and `panelRadius`.
## Base Scale
Use the same small scale across the whole component:
- Body text: `themeCssVariables.font.size.md`, regular weight, medium line height.
- Section titles and row titles: `themeCssVariables.font.size.md`, medium weight, medium line height.
- Descriptions and helper text: `themeCssVariables.font.size.sm`, regular weight, medium line height.
- Metadata labels and table headers: medium body text with `themeCssVariables.font.color.tertiary`.
- Compact controls: use Twenty `Button` sizing or `themeCssVariables.spacing[6]` for custom control height.
- Navigation and menu rows: use the native component when available; otherwise use the nearest theme spacing token that matches the host row rhythm.
- Dense table, list, and input-like rows: use `themeCssVariables.spacing[8]` for custom row height.
- Toolbars and view bars: use `themeCssVariables.spacing[10]` for custom toolbar height.
Do not increase type size to create hierarchy inside compact widgets. Use weight, color, alignment, spacing, and order first.
Applied example:
```tsx
const titleStyle = {
fontSize: themeCssVariables.font.size.md,
fontWeight: themeCssVariables.font.weight.medium,
lineHeight: themeCssVariables.text.lineHeight.md,
letterSpacing: 0,
color: themeCssVariables.font.color.primary,
};
const descriptionStyle = {
fontSize: themeCssVariables.font.size.sm,
fontWeight: themeCssVariables.font.weight.regular,
lineHeight: themeCssVariables.text.lineHeight.md,
letterSpacing: 0,
color: themeCssVariables.font.color.secondary,
};
```
## Radius And Shape
Use a small, consistent radius system:
- Cards, popovers, notification containers, and framed panels use `themeCssVariables.border.radius.md`.
- Buttons, chips, toolbar controls, menu hover fills, skeletons, and small fields use `themeCssVariables.border.radius.sm`.
- Checkbox shape and target sizing should come from the native Twenty checkbox pattern when available.
- Do not use pill or rounded radius for cards, rows, or toolbar buttons unless the existing Twenty primitive already does.
- Do not mix several custom radii in one component. Pick the matching Twenty radius token for each element role.
Applied example:
```tsx
const cardStyle = {
borderRadius: themeCssVariables.border.radius.md,
padding: themeCssVariables.spacing[2],
background: themeCssVariables.background.primary,
};
const toolbarButtonStyle = {
minHeight: themeCssVariables.spacing[6],
padding: `0 ${themeCssVariables.spacing[2]}`,
borderRadius: themeCssVariables.border.radius.sm,
};
```
## Spacing
Use the theme spacing scale instead of raw dimensions:
- `themeCssVariables.betweenSiblingsGap`: tightly grouped toolbar actions or small separators.
- `themeCssVariables.spacing[1]`: icon-to-label gaps, chip internals, compact row gaps.
- `themeCssVariables.spacing[2]`: default card padding, row horizontal padding, toolbar padding, title-row gaps.
- `themeCssVariables.spacing[4]`: section gaps inside larger panels or between unrelated groups.
- `themeCssVariables.spacing[6]`: left indentation for description text that belongs to a row with a leading icon and label.
Do not jump to large padding inside ordinary CRM controls. If a compact widget starts needing generous whitespace, the layout probably needs fewer sections or better grouping.
Applied example:
```tsx
const titleRowStyle = {
display: 'flex',
alignItems: 'center',
gap: themeCssVariables.spacing[2],
minHeight: themeCssVariables.spacing[6],
};
const descriptionRowStyle = {
paddingLeft: themeCssVariables.spacing[6],
paddingBottom: themeCssVariables.spacing[2],
};
```
## Information Hierarchy
Make hierarchy visible without making the component loud:
- Put the record, object, or task name first. Use medium body text with primary color.
- Put the explanation second. Use small regular text with secondary color.
- Put metadata third. Use `Tag`, `Status`, `Chip`, `Label`, `Avatar`, or tertiary text.
- Put counters and quiet totals in light text, not primary text.
- Use one primary action per small surface. Put secondary actions beside it or behind a menu.
- Use color for state and meaning, not decoration.
- Keep descriptions aligned under the title text, not under the leading icon.
- In a card with a leading icon, use a medium icon, a standard spacing gap, then text. The description starts at the same x-position as the title text.
- In a list or table, reserve the left edge for the most important identifier and keep it stable across every row.
Good hierarchy:
```tsx
<div style={{ display: 'grid', gap: themeCssVariables.spacing[1] }}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: themeCssVariables.spacing[2],
}}
>
<Avatar name={ownerName} />
<span style={titleStyle}>{companyName}</span>
<Status color="green" text="Active" />
</div>
<span style={descriptionStyle}>Renewal due in 12 days</span>
</div>
```
Avoid hierarchy that relies on oversized type, all caps, bright backgrounds, or extra padding. That breaks the compact CRM rhythm and makes dense data harder to scan.
## Alignment
Twenty UI looks precise because repeated edges line up:
- Align every custom surface to the theme spacing grid.
- Use `themeCssVariables.spacing[2]` horizontal padding for compact rows and cards.
- Use `themeCssVariables.spacing[6]` for icon-only action targets so icons share a stable center.
- Align row icons, avatars, labels, and actions vertically center.
- Keep list text left-aligned. Do not center-align row content.
- Keep toolbar context on the left and actions on the right.
- Keep repeated rows the same x-position, width, height, and action position.
- In menus, use a small inset from the menu edge and keep rows full width inside that inset.
- In tables, use the table theme tokens when available, especially `themeCssVariables.table.horizontalCellPadding`.
- Do not let a long value push action buttons out of alignment. Truncate text before actions move.
Toolbar pattern:
```tsx
const toolbarStyle = {
minHeight: themeCssVariables.spacing[10],
padding: themeCssVariables.spacing[2],
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: themeCssVariables.spacing[2],
};
const toolbarActionsStyle = {
display: 'flex',
alignItems: 'center',
gap: themeCssVariables.betweenSiblingsGap,
};
```
Table/list row pattern:
```tsx
const rowStyle = {
minHeight: themeCssVariables.spacing[8],
padding: `0 ${themeCssVariables.table.horizontalCellPadding}`,
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr) auto',
alignItems: 'center',
columnGap: themeCssVariables.spacing[2],
};
const cellLabelStyle = {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
};
```
## Consistency
Keep visual decisions repeatable:
- Use the same row height token for every row in the same list.
- Use the same action placement in loading, empty, error, and ready states.
- Use the same radius token for the same kind of element everywhere in the component.
- Use the same icon size token for the same role.
- Use the same label for the same action everywhere. Do not mix "Open", "View", and "See record" for one action.
- Use the same empty-state structure across related components: icon, short title, one sentence, one action.
- Use the same loading skeleton geometry as the final content. Do not replace compact rows with centered spinners unless the whole panel is blocked.
- Use Twenty tokens for color and spacing. Use raw values only for semantic data visualization that cannot be represented by existing tokens.
State backgrounds should follow the existing Twenty pattern:
- Default: transparent or `themeCssVariables.background.primary`.
- Hover: `themeCssVariables.background.transparent.light` or `themeCssVariables.background.secondary`.
- Pressed/clicked: `themeCssVariables.background.tertiary`.
- Selected: same shape and height as the default row, with selected color treatment from the nearest Twenty primitive.
- Disabled: same layout, lower contrast, no new decorative color.
- Loading: same dimensions as ready content, with skeleton blocks using the compact radius token.
## Cards And Panels
Use cards only for individual repeated items, notifications, popovers, or genuinely framed tools. Do not put cards inside cards.
Card defaults:
- Radius: `themeCssVariables.border.radius.md`.
- Padding: `themeCssVariables.spacing[2]` for compact cards.
- Title-to-body gap: `themeCssVariables.spacing[2]` only when the body is visually separate.
- Divider: `themeCssVariables.border.color.light` when sections need separation.
- Shadow: prefer `themeCssVariables.boxShadow.light` or the native host surface treatment. Do not combine a strong border with a strong shadow.
Overlay feedback pattern:
```tsx
const overlayFeedbackStyle = {
borderRadius: themeCssVariables.border.radius.md,
padding: `${themeCssVariables.spacing[2]} ${themeCssVariables.spacing[2]} ${themeCssVariables.spacing[1]}`,
display: 'flex',
flexDirection: 'column',
gap: themeCssVariables.spacing[1],
background: themeCssVariables.background.overlayPrimary,
backdropFilter: `blur(${themeCssVariables.blur.medium})`,
boxShadow: themeCssVariables.boxShadow.strong,
};
```
Use overlay styling for small feedback surfaces only. Normal embedded widgets should usually use the host background, a subtle border, or no frame.
## Buttons And Actions
Use actions that feel like Twenty toolbar controls:
- Use `Button` for actual commands.
- Use Twenty button `size` and `variant` props before custom button styling.
- For custom compact controls, use `themeCssVariables.spacing[6]` as the control height token.
- Use `themeCssVariables.spacing[2]` for horizontal padding.
- Use `themeCssVariables.spacing[1]` for icon-label gaps.
- Use `themeCssVariables.border.radius.sm` for custom compact action radius.
- Use medium body text for labels.
- Use medium icons for primary actions and small icons for quiet chevrons.
- Use `themeCssVariables.spacing[6]` for icon-only action targets.
Applied example:
```tsx
<div
style={{
display: 'flex',
alignItems: 'center',
gap: themeCssVariables.betweenSiblingsGap,
}}
>
<Button variant="secondary" size="small" title="Open record" />
<Button variant="secondary" size="small" title="Add note" />
</div>
```
## Tables And Dense Lists
Use table-like rhythm for CRM data:
- Header and row height: use `themeCssVariables.spacing[8]` for custom dense rows.
- Header text: medium body text with tertiary color.
- Cell text: regular body text with primary or secondary color.
- Cell padding: use `themeCssVariables.table.horizontalCellPadding`.
- Icon-label gap: use `themeCssVariables.spacing[1]`.
- Checkbox column: use `themeCssVariables.table.checkboxColumnWidth` when building table-like custom rows.
- First data column: make it the flexible `minmax(0, 1fr)` column so names get priority and actions stay aligned.
- Truncate long names with ellipsis. Do not wrap names in dense rows unless the row is intentionally two-line.
Applied example:
```tsx
const headerCellStyle = {
minHeight: themeCssVariables.spacing[8],
padding: `0 ${themeCssVariables.table.horizontalCellPadding}`,
display: 'flex',
alignItems: 'center',
gap: themeCssVariables.spacing[1],
fontSize: themeCssVariables.font.size.md,
fontWeight: themeCssVariables.font.weight.medium,
color: themeCssVariables.font.color.tertiary,
};
```
## Empty, Loading, Error, Disabled, Success
Design the visible states before polishing the ready state:
- Loading: preserve final layout dimensions with skeletons that use the same row, text, and radius tokens as the ready content.
- Empty: use `Callout` or a compact empty block with one short title, one sentence, and one clear action.
- Error: use `Callout` with an error icon, explain what failed, and offer retry or recovery.
- Disabled: keep the same control size and placement. Lower contrast and explain the blocking condition if it is not obvious.
- Success: use a small status, toast-style card, or inline confirmation. Do not redesign the whole panel for success.
Loading example:
```tsx
const skeletonStyle = {
height: themeCssVariables.spacing[4],
width: '60%',
borderRadius: themeCssVariables.border.radius.sm,
background: themeCssVariables.background.transparent.light,
};
```
Empty example:
```tsx
<Callout
variant="secondary"
title="No activity yet"
description="Activity will appear here once this record has timeline events."
/>
```
## Text And Copy
Make text fit in real Twenty widths:
- Use sentence case.
- Use short labels.
- Use short button text.
- Use one-sentence descriptions.
- Put dynamic values in stable containers with ellipsis.
- Never let a record name, email, URL, or amount overlap an action.
- Prefer exact object names over generic labels. Use "Open company", not "Open item", when the object is known.
- Keep units and counts close to the value: `12 opportunities`, `$4,200`, `3 tasks`.
Text truncation pattern:
```tsx
const truncateStyle = {
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
};
```
## Responsive Rules
Front components often render in narrow widgets:
- Design compact widths first.
- Use a single column by default.
- Move secondary actions into a menu before wrapping the whole toolbar.
- Keep the primary action visible when space is tight.
- Let content columns shrink with `minmax(0, 1fr)`.
- Use ellipsis for long identifiers instead of horizontal scroll in small widgets.
- Do not use hero-scale text, marketing sections, or wide decorative imagery in embedded CRM widgets.
Narrow layout pattern:
```tsx
const compactGridStyle = {
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr) auto',
alignItems: 'center',
gap: themeCssVariables.spacing[2],
};
```
## Quick Checks
Before considering a UI done, check this list:
- The component uses Twenty typography tokens, not custom text sizes.
- Cards use the medium radius token or a smaller existing primitive radius.
- Buttons and action controls use Twenty `Button` props or compact spacing tokens.
- Table/list rows use a consistent row-height token.
- Icons use `themeCssVariables.icon.size.*` or native Twenty icon sizing.
- Spacing uses `themeCssVariables.spacing[...]` and `themeCssVariables.betweenSiblingsGap`.
- Ready, loading, empty, error, and disabled states share the same structure.
- Important text has a stable left edge and truncates before it overlaps actions.
- Repeated rows have identical height, padding, icon size, action placement, and state behavior.
- The UI uses Twenty primitives and `themeCssVariables` before custom styling.
# Workflow
First, identify the target front component file and the user workflow it supports.
Design the visible states the user will naturally encounter: loading, empty, error, disabled, success, and the primary working state.
Then apply the rules in this order:
1. Set the information hierarchy: title, description, metadata, actions.
2. Lock the structural dimensions with Twenty primitives and `themeCssVariables`.
3. Align repeated edges and action positions.
4. Apply Twenty primitives and tokens.
5. Add hover, pressed, selected, loading, disabled, empty, error, and success states.
6. Test with long names, empty values, narrow widths, and multiple records.
@@ -0,0 +1,114 @@
# App Structure
Use this reference when creating or modifying files inside an existing Twenty app.
Use `../manage-app/cli-and-sync.md` for CLI command behavior, remotes, authentication, sync troubleshooting, build, deploy, logs, and CI/CD.
## App Checks
Before changing app entities, confirm the current directory is a Twenty app:
```bash
test -f package.json
test -f src/application-config.ts
```
If this fails, do not edit from the wrong folder. Check the current path and nearby app roots:
```bash
pwd
find . -maxdepth 3 -name package.json -o -path '*/src/application-config.ts'
```
Move to the matching app root before continuing. If no app exists, use `create-app`. If the folder exists but tooling, dependencies, remotes, authentication, sync, or builds are broken, use `manage-app` before changing app entities.
Inspect the app shape:
```bash
sed -n '1,220p' package.json
sed -n '1,220p' src/application-config.ts
find src -maxdepth 3 -type f | sort
find public -maxdepth 2 -type f | sort
```
## Entity Creation
Prefer the app CLI for new entities when interactive prompts are acceptable:
```bash
yarn twenty dev:add
```
For non-interactive agent work, direct file creation is often better. Use generated CLI templates, local SDK typings, or existing app files as the source of truth for imports and config shape.
Use official Twenty docs or local SDK source when exact imports, entity fields, or configuration shapes matter.
## Source Layout
Add these alongside the scaffolded `src/{fields,objects,logic-functions,front-components,page-layouts,navigation-menu-items,constants}/` as needed:
- `src/utils/` — pure helpers as `<name>.util.ts`, kept flat.
- `src/types/` — one PascalCase type per file.
- `src/<service>-client/` — wrappers around external SDKs or HTTP clients. One folder per service, matching Twenty's `*-client` convention (`redis-client/`, `sdk-client/`, ...).
- Tests as `*.spec.ts` in sibling `__tests__/` folders next to the code they cover.
Filenames are kebab-case; conventional suffixes are `.util.ts`, `.dto.ts`, `.service.ts`, `.spec.ts` — matching the Twenty backend.
### One Export Per File
Every helper, type, and client file exports exactly one thing. The rule counts exports, not declarations — there are no exceptions for `utils/`, `types/`, or `<service>-client/`.
- Never put multiple function exports in the same file. One function per file, each with its own sibling spec.
- A file must never export more than one thing. A local (non-exported) type used only by the util may live in the same file, but the moment a type is exported or reused elsewhere it moves to `src/types/<name>.ts` while the util stays in `src/utils/<name>.util.ts`.
```ts
// ❌ Bad — src/utils/parse-company.util.ts exports a type and a util
export type ParsedCompany = { id: string; name: string };
export const parseCompany = (raw: RawCompany): ParsedCompany => { /* ... */ };
// ✅ Good — src/utils/parse-company.util.ts (one export; the type is local)
type ParsedCompany = { id: string; name: string };
export const parseCompany = (raw: RawCompany): ParsedCompany => { /* ... */ };
// ✅ Good — when the type is shared, split it
// src/types/parsed-company.ts (one PascalCase type)
export type ParsedCompany = { id: string; name: string };
// src/utils/parse-company.util.ts (one util)
import { type ParsedCompany } from 'src/types/parsed-company';
export const parseCompany = (raw: RawCompany): ParsedCompany => { /* ... */ };
```
Entity files are consistent with this rule: front components, logic functions, and post-install hooks use a single `export default define...()`, which is one export.
Files inside `src/types/` use plain `<name>.ts` (one PascalCase type per file) — no `.type.ts` suffix. Files inside `src/<service>-client/` also use plain `<name>.ts`; the folder name carries the suffix, so do not repeat it on the file.
When field definitions differ only by `objectUniversalIdentifier` and label, replace them with a factory in `src/fields/field-factories.ts`.
Read secrets through the application-config helper, not raw `process.env`.
## Boundaries
- Do not scaffold a new app from this workflow. Use `create-app` first when the app does not exist.
- Do not guess generated entity shapes when the CLI or docs can provide them.
- Keep app changes scoped to the requested feature and its required registrations.
## Validation Checklist
Once all edits for the change are complete, run lint and typecheck once at the end (not after each individual edit), then sync the app to verify the definitions are valid:
```bash
yarn twenty dev:typecheck
yarn lint
yarn twenty dev --once
```
`yarn twenty dev:typecheck` checks generated app types and `yarn lint` checks local lint rules. `yarn twenty dev --once` then builds the app and pushes entity definitions to the active remote; if any definition is invalid, the sync reports the error. Run all three a single time once every edit is done, not repeatedly after each step.
When the user explicitly asks to run tests, follow `tests.md`: unit tests may use the package's unit-test script, and the full suite must run with `TWENTY_API_URL=http://localhost:2021` against the isolated test instance.
Switch to `manage-app` and use `../manage-app/cli-and-sync.md` for sync or remote troubleshooting.
Tests are not part of sync validation but should cover `src/utils/` helpers and post-install hook idempotency. See `tests.md`.
@@ -0,0 +1,108 @@
# Data Model
Use this reference for Twenty app objects, fields, relations, roles, and permissions.
## Objects And Fields
Objects and fields define the records users will create, view, search, and automate. Model the user's workflow first, then add the smallest set of fields that makes the workflow usable.
When adding or modifying data model entities:
- Prefer generated app entity patterns from `yarn twenty dev:add`.
- Use clear object and field names that map to user-facing language.
- Add relations only when users need to navigate or report across records.
- Avoid duplicating data that already exists on core Twenty objects.
- Keep field types specific enough to support filtering, views, and automation.
### Usable Object Pattern
When the user asks for a new object and does not explicitly restrict the request to schema only, make the object usable in the app by pairing data model work with the minimum layout/navigation surfaces:
- Object file in `src/objects/<name>.ts`
- Table view in `src/views/all-<plural>.ts`
- Object navigation item in `src/navigation-menu-items/<name>.ts`
- Record page layout in `src/page-layouts/<name>-record-page-layout.ts`
- Fields-widget view for the record page in `src/views/<name>-record-page-fields.ts`
Use `layout.md` for view, navigation, and page layout details.
Objects support `icon`, but object color is not defined on `defineObject()`. Put color on the object navigation item:
```ts
import {
defineNavigationMenuItem,
NavigationMenuItemType,
} from 'twenty-sdk/define';
export default defineNavigationMenuItem({
universalIdentifier: '<uuid>',
name: '<name>',
icon: '<IconName>',
color: '<color>',
position: 0,
type: NavigationMenuItemType.OBJECT,
targetObjectUniversalIdentifier: '<object-uuid>',
});
```
### Minimal Object Example
```ts
import { defineObject, FieldType } from 'twenty-sdk/define';
export const OBJECT_UNIVERSAL_IDENTIFIER = '<uuid>';
export const NAME_FIELD_UNIVERSAL_IDENTIFIER = '<uuid>';
export default defineObject({
universalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER,
nameSingular: '<name>',
namePlural: '<names>',
labelSingular: '<Name>',
labelPlural: '<Names>',
icon: '<IconName>',
labelIdentifierFieldMetadataUniversalIdentifier:
NAME_FIELD_UNIVERSAL_IDENTIFIER,
fields: [
{
universalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.TEXT,
name: 'name',
label: 'Name',
icon: 'IconAbc',
},
],
});
```
### Select Fields
Select and multi-select option `value` strings must be uppercase snake case:
```ts
{
type: FieldType.SELECT,
name: 'status',
label: 'Status',
defaultValue: "'PLANNED'",
options: [
{ position: 0, label: 'Planned', value: 'PLANNED', color: 'sky' },
{ position: 1, label: 'In build', value: 'IN_BUILD', color: 'orange' },
],
}
```
For select fields, default values must be quoted string expressions like `"'PLANNED'"`, not plain strings like `'planned'`.
## Roles And Permissions
Roles should match operational responsibility, not implementation convenience.
When adding permissions:
- Grant the smallest useful scope.
- Keep sensitive objects and fields out of broad roles.
- Check whether the app introduces side effects through logic functions before granting write access.
## Verification
After data model changes, run the app and verify the objects, fields, and roles appear where the user will manage or use them.
@@ -0,0 +1,121 @@
# Front Components
Use this reference for Twenty app front component source files, registration, data access, Twenty UI imports, runtime imports, and browser verification.
Use `layout.md` for placing a front component in a page layout. Use `standalone-pages.md` for full-page custom UI rendered through a front component widget. Use `../design/front-component-ui.md` for visual design, spacing, states, and polish. Use `app-structure.md` for the develop-app validation checklist and `../manage-app/cli-and-sync.md` for command behavior or troubleshooting.
## Source And Registration
By convention, keep front components under `src/front-components/`, often as `<name>.front-component.tsx`.
Register the component with `defineFrontComponent`:
```tsx
import { defineFrontComponent } from 'twenty-sdk/define';
const MyFrontComponent = () => {
return <div />;
};
export default defineFrontComponent({
universalIdentifier: '<front-component-uuid>',
name: '<front-component-name>',
description: '<short description>',
component: MyFrontComponent,
});
```
Do not import or call `react-dom/client` in the component source. The SDK renderer mounts front components.
## Runtime Imports
Keep imports narrow:
- Use `twenty-sdk/front-component` for front component hooks and host APIs.
- Use `twenty-client-sdk/core` or `twenty-client-sdk/metadata` for data access.
- Use `twenty-sdk/ui` for Twenty UI components, icons, and theme tokens before adding external UI libraries.
- Do not import from `twenty-ui` directly. Use the SDK re-export so build aliases and runtime packaging stay aligned.
The front component renderer provides a Twenty `ThemeProvider` around the remote root. For isolated examples or local story-style verification, wrapping the component in `ThemeProvider` from `twenty-sdk/ui` is also acceptable.
The canonical Twenty UI front component example is `packages/twenty-front-component-renderer/src/__stories__/example-sources/twenty-ui-example.front-component.tsx`. It imports `Button`, `Chip`, `H2Title`, `Status`, `Tag`, and `ThemeProvider` from `twenty-sdk/ui`.
When using `themeCssVariables`, compute styles inside the component body or a function called by the component. The SDK mocks `twenty-sdk/ui` during manifest extraction, so module-level constants that dereference `themeCssVariables` can fail before the component renders.
Prefer Twenty UI icons from `twenty-sdk/ui` when one exists. Use inline SVG only for app-specific marks or icons that are not available through the SDK export.
## Record Context And Data
For record-page components, read selection from front component context:
- Use `useSelectedRecordIds()` for single, bulk, or empty selection; derive one id only when the array length is 1.
Use the generated or core Twenty client for reads and writes. Keep loading, empty, error, disabled, and saving states explicit so runtime failures are visible and recoverable.
## Headless Actions And DRY Helpers
Headless front components should be thin action shells. The component file should mostly read SDK hooks, return the `Command` helper, and delegate reusable behavior to helpers.
Common headless action flow:
1. Read selected record IDs.
2. Load the selected records.
3. Build a payload for one logic-function call.
4. Execute the logic function.
5. Parse the result summary.
6. Show a snackbar.
7. Let `Command` unmount the component.
Do not duplicate this orchestration across sibling front components. When two components share command execution flow, extract it before copying:
- Pure helpers go in `src/utils/<name>.util.ts`.
- Front-component runtime helpers go in `src/front-components/utils/<name>.util.ts`.
- Payload builders, result parsers, selected-record validators, and summary formatters should be small testable functions with sibling specs.
Each extracted helper and type lives in its own file: one export per file. A local, non-exported type may stay with the util, but an exported type moves to its own file. See `app-structure.md`.
Prefer configuration-driven helpers when creating parallel actions for people, companies, tasks, or other objects. Each front component should provide only object-specific configuration, such as the front component universal identifier, logic function universal identifier, record query, payload builder, labels, and snackbar copy.
## Bulk Logic Function Calls
When a front component triggers a logic function for selected records, always prefer a bulk payload shape unless the user explicitly says the logic function is only for one record. The front component should call the logic function once with all selected records, not loop and execute the same logic function once per record.
Default payload shape:
```ts
{
records: Array<{
id: string;
// object-specific fields used by the logic function
}>;
}
```
Inside `records`, prefer `id` for the Twenty record ID because the array name already establishes record context. Do not add flat `recordId` payloads unless the user explicitly asks for a single-record action.
Logic functions that return per-record outcomes should mirror the same naming:
```ts
{
ok: boolean;
enrichedCount: number;
noMatchCount: number;
failedCount: number;
results: Array<{
id: string;
status: 'ENRICHED' | 'NO_MATCH' | 'FAILED';
error?: string;
}>;
}
```
If an existing logic function accepts only a flat record ID, prefer upgrading it to accept `records` and remove the old flat input unless the user explicitly asks to preserve it.
## Runtime Verification
A clean typecheck and sync is not runtime verification. After the standard validation in `app-structure.md`, open the relevant Twenty surface and confirm:
- The widget mounts without a `FrontComponent error` in the widget body or toast.
- The component renders loading, empty, and error states correctly.
- The main user action works against a real record.
- The page still renders after a hard refresh.
@@ -0,0 +1,50 @@
# Layout
Use this reference for Twenty app views, navigation, page layouts, page layout tabs, and front component registration. Use `standalone-pages.md` when a page layout is meant to host a full-page custom UI.
## Views And Navigation
Views and navigation define the first-run experience. Add only the surfaces users need to understand and operate the app.
When changing views:
- Put the most common workflow first.
- Use concise names that match the data model.
- Keep list, board, and detail surfaces consistent with existing Twenty patterns.
- Avoid creating navigation entries for low-frequency admin tasks unless users need repeated access.
## Page Layouts
Page layouts should make the record's current state and next action easy to scan.
When adding layouts or tabs:
- Group related fields and components.
- Keep important record status visible without scrolling when possible.
- Place front components where they support the surrounding record context.
- Include empty and loading behavior for front components shown on record pages.
## Front Component Widgets
When adding an app-defined front component to a record page layout, use the component's universal identifier in the widget configuration:
```ts
{
universalIdentifier: '<widget-uuid>',
title: '<Widget title>',
type: 'FRONT_COMPONENT',
objectUniversalIdentifier: '<object-uuid>',
configuration: {
configurationType: 'FRONT_COMPONENT',
frontComponentUniversalIdentifier: '<front-component-uuid>',
},
}
```
Use `frontComponentUniversalIdentifier` for app-defined front components. A `frontComponentId` is not the same value and will not link the widget to the app component correctly.
## Verification
Run the app and inspect the user path from navigation to view to record detail. The route should be discoverable without relying on implementation knowledge.
For front component implementation and runtime verification after placement, use `front-components.md`.
@@ -0,0 +1,103 @@
# Logic
Use this reference for Twenty app logic functions, skills, agents, and connection providers.
## Logic Functions
A logic function file should contain trigger registration, input validation, the call out to the work, and the writes back — nothing else. Everything else lives next to it (see `app-structure.md`):
- External API calls → `src/<service>-client/<name>.ts`.
- Response parsing and mapping → `src/utils/<name>.util.ts`.
- Response and DTO types → `src/types/<name>.ts`.
- Shared structure across object types → one mapper factory parameterized by object kind, not parallel functions.
Kebab-case filenames, one export per file. Response/DTO types go in `src/types/<name>.ts` and parsing/mapping helpers in `src/utils/<name>.util.ts` — never export both a type and a util from the same file (a local, non-exported type may stay with the util), and never put multiple function exports in one file. See `app-structure.md`.
Other rules:
- Validate required fields before writes or remote calls.
- Prefer bulk inputs for record actions. If a logic function can be triggered from selected records, the canonical input should be `records: Array<{ id: string; ...fields }>` unless the user explicitly says the function is only for one record.
- Use `id` inside a `records` array for the Twenty record ID. Do not add `recordId`, object-specific IDs such as `companyId`, or flat single-record payloads unless the user explicitly requests a single-record contract.
- Return a bulk summary with per-record results for multi-record actions, including counts for success, no match, and failed records.
- Prefer idempotent behavior for jobs and repeated invocations.
- Read secrets through the application-config helper, not raw `process.env`.
- Do not hide customer-impacting side effects behind UI-only actions.
Soft cap: a `*.logic-function.ts` or `*.post-install.ts` file over 200 lines is a refactor signal.
## Bulk Record Actions
Bulk is the default logic-function contract for actions that may run from front component selection. The front component should gather selected records and invoke the function once. Do not make the front component loop over selected records and execute the same logic function repeatedly unless there is an explicit single-record requirement.
Preferred input:
```ts
type BulkInput<TRecord extends { id: string }> = {
records: TRecord[];
};
```
Preferred output:
```ts
type BulkResult = {
ok: boolean;
enrichedCount: number;
noMatchCount: number;
failedCount: number;
results: Array<{
id: string;
status: 'ENRICHED' | 'NO_MATCH' | 'FAILED';
pdlId?: string;
error?: string;
}>;
};
```
For existing single-record functions that are being upgraded to selected-record actions, replace the old flat input with `records: Array<{ id: string; ...fields }>` unless the user explicitly asks for backward compatibility.
Extract and test:
- input normalization for the canonical bulk shape;
- per-record validation;
- external API payload mapping;
- per-record result mapping;
- summary count aggregation;
- error message normalization.
## Skills And Agents
Skills and agents should describe when they apply, what context they need, and what output is expected.
When adding AI behavior:
- Make trigger rules concrete.
- Keep instructions grounded in available app data and tools.
- State when the agent should ask for missing workspace or record context.
- Avoid exposing raw IDs, timestamps, or nested API output to end users when a readable answer is possible.
## Connection Providers
For third-party connections:
- Keep secrets out of source and public assets.
- Document required OAuth or API setup in the app README or listing.
- Verify failure states for expired or missing credentials.
## Post-Install Hooks
Use `definePostInstallLogicFunction` for records that must exist on install — default workflows, views, roles, or seeded reference data. Do not implement this as runtime first-run code.
Post-install hook files live alongside other logic functions (typically `src/logic-functions/<name>.post-install.ts`). Kebab-case filename, one export per file.
Hooks must be idempotent: find by stable identifier before creating, update if it exists, never duplicate. Treat a not-found from a single-record query as "needs create."
Do not write fields Twenty computes elsewhere (workflow `statuses` is computed from version status — see `workflows.md`).
Dev sync skips install hooks. Invoke locally:
```bash
yarn twenty dev:function:exec
```
Run again after rebuilding to verify idempotency.
@@ -0,0 +1,458 @@
# Standalone Pages
Use this reference when a Twenty app needs a full-page custom UI: an operational console, map, canvas, planner, status wall, or other page-sized tool.
For general page layout and navigation entities, use `layout.md`. For front component source, runtime imports, hooks, data access, and browser verification, use `front-components.md`. For visual polish and Twenty UI component choices, use `../design/front-component-ui.md`. For CLI and deployment command details, use `../manage-app/cli-and-sync.md`.
This reference owns only the standalone-page assembly pattern and full-page behavior. It repeats small code fragments where they are needed to show how the pieces connect, but leaves general API behavior to the linked references.
## Mental Model
A standalone page is not a raw page body component. In the current local app pattern, custom page content should be rendered through a `FRONT_COMPONENT` widget inside a `STANDALONE_PAGE` page layout. There does not appear to be a separate public "page body component" API for app-defined standalone pages.
The current model is:
1. Define stable universal identifiers.
2. Register the page experience with `defineFrontComponent`.
3. Place that front component in a `definePageLayout` with `type: 'STANDALONE_PAGE'`.
4. Surface the page with `defineNavigationMenuItem` using `NavigationMenuItemType.PAGE_LAYOUT`.
5. Sync or install the app.
6. Twenty resolves the sidebar item to the `/page/:pageLayoutId` route and renders the front component widget inside the page layout.
Use these surfaces for different jobs:
| Surface | Use it for | Primary app entity |
| --- | --- | --- |
| Standalone page | A full workspace page that is not tied to one record | `STANDALONE_PAGE` + `PAGE_LAYOUT` navigation item + `FRONT_COMPONENT` widget |
| Record page layout | Tabs and widgets for one object record | `RECORD_PAGE` page layout or page layout tab |
| Dashboard | Metric and report composition from built-in widgets | `DASHBOARD` page layout |
| Command or side panel | Short actions, focused forms, one selected record, or background commands | `defineFrontComponent` plus command menu item |
## Removing The Scaffolded Placeholder
Every freshly scaffolded app contains three placeholder files that wire a "Welcome" sidebar item to a generic landing page:
- `src/front-components/main-page.tsx`
- `src/page-layouts/main-page.page-layout.ts`
- `src/navigation-menu-items/main-page.navigation-menu-item.ts`
If the app has no user-facing page — for example, it only extends standard objects, declares logic functions, or seeds workflows — delete all three files before the first deploy. Leaving them in ships a dead sidebar item.
## Quickstart
Use this minimal file set:
- `src/constants/universal-identifiers.ts`
- `src/front-components/<name>.front-component.tsx`
- `src/page-layouts/<name>.page-layout.ts`
- `src/navigation-menu-items/<name>.navigation-menu-item.ts`
Start with a tiny component that proves the route works before building the full page.
```ts src/constants/universal-identifiers.ts
export const MISSION_CONTROL_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
'a0a1c4f0-f23a-4c59-93c5-92146d64b110';
export const MISSION_CONTROL_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER =
'63d4970d-6c54-49c8-9c15-52d7cb00fb7a';
```
```tsx src/front-components/mission-control.front-component.tsx
import { defineFrontComponent } from 'twenty-sdk/define';
import {
MISSION_CONTROL_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
} from '../constants/universal-identifiers';
const MissionControl = () => {
return (
<main
style={{
boxSizing: 'border-box',
display: 'grid',
minHeight: '100%',
padding: 24,
placeItems: 'center',
width: '100%',
}}
>
<h1 style={{ margin: 0 }}>Mission Control</h1>
</main>
);
};
export default defineFrontComponent({
universalIdentifier: MISSION_CONTROL_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'mission-control',
description: 'Standalone mission control page.',
component: MissionControl,
});
```
```ts src/page-layouts/mission-control.page-layout.ts
import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk/define';
import {
MISSION_CONTROL_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
MISSION_CONTROL_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
} from '../constants/universal-identifiers';
export default definePageLayout({
universalIdentifier: MISSION_CONTROL_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
name: 'Mission Control',
type: 'STANDALONE_PAGE',
tabs: [
{
universalIdentifier: 'e6963ad3-e5fa-41c7-83e1-4fc2ca5de9a8',
title: 'Mission Control',
position: 0,
icon: 'IconRocket',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier: '18ce05bb-ee3c-4332-80a7-f8fb84f7f70a',
title: 'Mission Control',
type: 'FRONT_COMPONENT',
gridPosition: { row: 0, column: 0, rowSpan: 12, columnSpan: 12 },
configuration: {
configurationType: 'FRONT_COMPONENT',
frontComponentUniversalIdentifier:
MISSION_CONTROL_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
},
},
],
},
],
});
```
```ts src/navigation-menu-items/mission-control.navigation-menu-item.ts
import {
defineNavigationMenuItem,
NavigationMenuItemType,
} from 'twenty-sdk/define';
import {
MISSION_CONTROL_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
} from '../constants/universal-identifiers';
export default defineNavigationMenuItem({
universalIdentifier: '61d44a63-16e8-4fbe-bccb-9c220d44fdb9',
name: 'Mission Control',
icon: 'IconRocket',
color: 'blue',
position: 50,
type: NavigationMenuItemType.PAGE_LAYOUT,
pageLayoutUniversalIdentifier:
MISSION_CONTROL_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
});
```
Use `PageLayoutTabLayoutMode.CANVAS` for the full-page renderer. Keep the 12 x 12 fill pattern as a grid fallback and editing hint; CANVAS renders the first widget as the page-sized surface.
After the tiny page renders, replace the component body with a full-screen structure:
```tsx
const MissionControl = () => {
return (
<main
style={{
boxSizing: 'border-box',
display: 'grid',
gap: 16,
gridTemplateRows: 'auto minmax(0, 1fr)',
height: '100%',
minHeight: '100%',
overflow: 'hidden',
padding: 16,
width: '100%',
}}
>
<header style={{ display: 'flex', justifyContent: 'space-between' }}>
<h1 style={{ margin: 0 }}>Mission Control</h1>
<button type="button">Refresh</button>
</header>
<section
style={{
display: 'grid',
gridTemplateColumns: '280px minmax(0, 1fr)',
minHeight: 0,
overflow: 'hidden',
}}
>
<aside style={{ minHeight: 0, overflow: 'auto' }}>Filters</aside>
<div style={{ minHeight: 0, overflow: 'auto' }}>Workspace data</div>
</section>
</main>
);
};
```
## API Reference
This section only calls out the fields that matter for standalone pages. Use `layout.md` and `front-components.md` for broader entity guidance.
`definePageLayout` owns the standalone route target:
- Use `type: 'STANDALONE_PAGE'`.
- Do not set `objectUniversalIdentifier`; standalone pages are not record scoped.
- Define at least one tab. Use one tab unless the page needs real top-level modes.
- Use `PageLayoutTabLayoutMode.CANVAS`; put the `FRONT_COMPONENT` first and keep a 12 x 12 `gridPosition` as fallback/editing intent.
- Use a `FRONT_COMPONENT` widget with `configurationType: 'FRONT_COMPONENT'` and `frontComponentUniversalIdentifier`.
`defineFrontComponent` owns the actual page experience:
- Register a visible component with `component`, `name`, `description`, and a stable `universalIdentifier`.
- Use `twenty-sdk/front-component` for runtime hooks, host navigation, snackbars, application variables, and side panel actions.
- Use `twenty-client-sdk/core` for workspace records when the page is data driven.
- Use `getPublicAssetUrl` from `twenty-sdk/define` for app-bundled images or static files.
`defineNavigationMenuItem` owns sidebar reachability:
- Use `type: NavigationMenuItemType.PAGE_LAYOUT`.
- Set `pageLayoutUniversalIdentifier` to the standalone page layout universal identifier.
- Set a clear `name`, `icon`, `color`, and `position`.
- Use `folderUniversalIdentifier` only when the page belongs under an existing app folder.
Public assets and non-secret application variables make standalone pages richer without hardcoding environment data:
```tsx
import { defineFrontComponent, getPublicAssetUrl } from 'twenty-sdk/define';
import { getApplicationVariable } from 'twenty-sdk/front-component';
const logoUrl = getPublicAssetUrl('mission-control-logo.png');
const MissionBrand = () => {
const label = getApplicationVariable('MISSION_LABEL') ?? 'Mission Control';
return <img src={logoUrl} alt={label} />;
};
```
## Full-Page Layout Guidance
The front component only fills the page if every layer inside the widget has deterministic sizing. Start the root at `height: '100%'`, `minHeight: '100%'`, `width: '100%'`, and `boxSizing: 'border-box'`.
Use `minmax(0, 1fr)` and `minHeight: 0` for scrollable grid or flex children. Without these constraints, inner tables, maps, and canvases can force the page taller than the widget or collapse into a zero-height area.
Prefer one immersive tool surface over a dashboard made of many small cards. If the page is a mission tracker, map, editor, kanban, planner, or cockpit, make the front component own the composition and use internal panels only where they support the workflow.
Assume the available area changes with Twenty chrome, the left sidebar, side panel state, tab list, and smaller screens. Use responsive CSS inside the front component:
- Collapse side filters above or below the main surface on narrow widths.
- Keep map, canvas, table, and timeline containers at `minHeight: 0`.
- Make only intentional regions scroll.
- Keep loading, empty, and error states inside the same sized root so the page never goes blank while data changes.
Avoid hidden overflow traps. `overflow: 'hidden'` is useful for map and canvas roots, but pair it with explicit scroll containers for lists and diagnostics.
## Data And Interactivity
Fetch live workspace records from the front component when the page reflects workspace state:
Use `front-components.md` for general client/runtime rules. In standalone pages, the key additions are visible full-page loading, empty, and error states, plus navigation from the standalone surface back into Twenty records.
```tsx
import { useEffect, useState } from 'react';
import { CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core';
import { defineFrontComponent } from 'twenty-sdk/define';
import {
AppPath,
enqueueSnackbar,
navigate,
} from 'twenty-sdk/front-component';
type CompanySummary = Pick<CoreSchema.Company, 'id' | 'name'>;
const CompaniesStandalonePage = () => {
const [companies, setCompanies] = useState<CompanySummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
const loadCompanies = async () => {
try {
setLoading(true);
setError(null);
const client = new CoreApiClient();
const result = await client.query({
companies: {
edges: {
node: {
id: true,
name: true,
},
},
},
});
if (!cancelled) {
setCompanies(result.companies.edges.map((edge) => edge.node));
}
} catch (err) {
const message =
err instanceof Error ? err.message : 'Failed to load companies';
if (!cancelled) {
setError(message);
enqueueSnackbar({ message, variant: 'error' });
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
};
loadCompanies();
return () => {
cancelled = true;
};
}, []);
if (loading) return <div style={{ padding: 24 }}>Loading companies...</div>;
if (error) return <div style={{ padding: 24 }}>{error}</div>;
if (companies.length === 0) {
return <div style={{ padding: 24 }}>No companies yet.</div>;
}
return (
<div style={{ display: 'grid', gap: 8, padding: 24 }}>
{companies.map((company) => (
<button
key={company.id}
type="button"
onClick={() =>
navigate(AppPath.RecordShowPage, {
objectNameSingular: 'company',
objectRecordId: company.id,
})
}
>
{company.name}
</button>
))}
</div>
);
};
export default defineFrontComponent({
universalIdentifier: '5aa8c51f-72a4-4929-a6ab-2d4ea9d2a6df',
name: 'companies-standalone-page',
description: 'Lists companies and opens related records.',
component: CompaniesStandalonePage,
});
```
Always model these states:
- Loading: show progress in the same page shell that the loaded view uses.
- Empty: explain what is missing and offer the next action if one exists.
- Error: show the message and keep a retry path visible.
- Auth refresh: token failures can happen during development or long-lived sessions; surface the failure instead of returning `null`.
- Demo data: label fake data clearly and keep the switch to live data obvious.
Use `navigate` for full-page routes and `openSidePanelPage` for focused side-panel workflows. Prefer navigating to the record route when the user is leaving the standalone experience to inspect a real record.
## Debugging
For a black screen, check the simplest causes first:
- Component runtime exception: open the browser console and look for a front component error, React error, or Remote DOM unsupported operation.
- Missing data fallback: temporarily replace the component with the tiny "Mission Control" component from this reference.
- Token or fetch failure: log the caught error, confirm `CoreApiClient` generation, and show an error state.
- Public asset failure: verify `getPublicAssetUrl(...)` output in the network tab and render without the asset.
- CSS layer covering content: remove absolute overlays, `zIndex`, and full-screen backgrounds until text is visible.
- Zero-height container: add visible borders and confirm `height: '100%'`, `minHeight: '100%'`, `minHeight: 0`, and the 12 x 12 widget grid position.
- Unsupported browser or Remote DOM behavior: remove unusual DOM APIs, portals, global document access, and third-party components until the minimal UI renders.
- Stale deployed app version: confirm the installed app version is the one you just synced or deployed.
Console checks:
```tsx
console.info('Standalone page mounted');
console.info('Companies loaded', companies.length);
```
Installed-app checks:
- Confirm the app appears in Twenty settings or the application developer view.
- Confirm the sidebar item appears with the expected icon and label.
- Confirm clicking the sidebar item opens `/page/:pageLayoutId`.
- Confirm the page still renders after a hard refresh.
Known-good component test:
```tsx
const KnownGoodStandalonePage = () => (
<main style={{ minHeight: '100%', padding: 24 }}>
<h1>Standalone page runtime is working</h1>
</main>
);
```
If this renders but the full page does not, the issue is inside the full component. If this does not render, inspect the layout, navigation, sync, installation, and front component registration.
## Deployment And Verification
Use local dev sync while iterating and one-shot sync for bounded verification. Use `../manage-app/cli-and-sync.md` for exact command behavior, remote setup, verbose troubleshooting, deploys, and logs.
```bash
yarn twenty dev --once
```
Install and update flow:
- During development, sync against the active remote and confirm the app appears as installed in the target workspace.
- For an already installed app, sync or deploy the updated version, then reopen the sidebar route and hard refresh the page.
- For packaged deploys, bump `package.json` before publishing an update to a workspace that already has the app installed.
Versioning expectations:
- Dev sync updates the active remote during development.
- Deploying a packaged update requires a strictly higher `package.json` version than the installed version.
- Users may need to update or reinstall the app depending on how the target workspace receives application updates.
Acceptance checks:
- Sidebar item appears in the expected section or folder.
- Sidebar item opens the standalone page route.
- The front component renders visible content.
- The component fills the widget/page area at desktop size.
- Data loads from live workspace records or a clearly labeled demo source.
- Loading, empty, and error fallbacks are visible and nonblank.
- Record navigation or side panel interactions work.
- Desktop and smaller-screen screenshots are nonblank and do not show overlapping controls.
## Examples
Minimal standalone page:
- One front component.
- One `STANDALONE_PAGE` page layout.
- One `PAGE_LAYOUT` navigation item.
- One 12 x 12 `FRONT_COMPONENT` widget.
Full-screen operational page:
- Root grid with header and `minmax(0, 1fr)` body.
- Left filter panel, central work surface, optional right inspector.
- Loading, empty, error, and retry UI inside the same shell.
Data-driven page that opens related records:
- Fetch records with `CoreApiClient`.
- Render a list, table, timeline, or map.
- Use `navigate(AppPath.RecordShowPage, { objectNameSingular, objectRecordId })` for record drill-in.
Immersive canvas or map-style page, such as a Space X Mission Tracking page:
- Keep the map/canvas as the main full-height surface.
- Put filters, mission status, and selected mission details in internal panels.
- Use public assets for mission patches or map overlays.
- Avoid composing the page as dashboard cards unless the user is primarily comparing metrics.
@@ -0,0 +1,46 @@
# Tests
Tests use the `*.spec.ts` extension and live in sibling `__tests__/` folders next to the code they cover, matching Twenty backend conventions. Write them where `yarn twenty dev --once` does not validate correctness.
Always write a test file for every util or function. Whenever you create or modify a file in `src/utils/` (or any other testable function file), you MUST create or update its sibling `__tests__/<name>.spec.ts`. A util or function without a spec file is incomplete.
## File Organization
Write one spec file per util or function source file. Each testable source file gets a dedicated sibling `__tests__/<name>.spec.ts` whose name mirrors the source file. Do not combine multiple utils or functions into a single spec file.
```
src/utils/parse-foo.util.ts
src/utils/__tests__/parse-foo.util.spec.ts
src/utils/map-bar.util.ts
src/utils/__tests__/map-bar.util.spec.ts
```
## What To Test
- Parsers in `src/utils/` — every branch of foreign API shape handling, including missing fields and empty responses.
- Mappers in `src/utils/` — foreign-to-Twenty mapping, including the "no match" case.
- Front-component helpers in `src/front-components/utils/` — selected-record validation, payload builders, logic-function result parsing, summary aggregation, and snackbar message formatting.
- Bulk logic-function normalization — the canonical `records: Array<{ id: string; ...fields }>` shape, including empty arrays and records missing `id`.
- Post-install hooks — at minimum, idempotency: run twice, assert state is identical.
- Side effects (billing, external state) — assert they fire only on the conditions the code claims.
## What To Skip
- Entity definitions (`*.field.ts`, `*.object.ts`) — sync validates these.
- Front component rendering shells — verify in the browser. Extracted helper functions are not rendering shells and must be unit-tested.
## Running Tests
Integration tests build, deploy, install, and uninstall the app on whatever server `TWENTY_API_URL` points to. The scaffold defaults to the dev instance (`http://localhost:2020`), so running them there adds and then removes the app from the workspace you sync to with `yarn twenty dev --once`. Always run them against the isolated test instance instead — a separate container, database, and port (`2021`) that does not affect your running dev instance:
```bash
# Start the isolated test instance (once).
yarn twenty docker:start --test
# Run integration tests against it.
TWENTY_API_URL=http://localhost:2021 yarn test
```
The seeded default `TWENTY_API_KEY` works for both instances, so only the URL needs overriding. This mirrors CI, which spawns the same isolated instance via the `spawn-twenty-app-dev-test` action.
When the user asks you to run tests, do run them. First start or verify the isolated test instance, then run the test command with `TWENTY_API_URL=http://localhost:2021`. Do not run integration tests against `http://localhost:2020` unless the user explicitly asks to target the dev instance. If only unit tests are requested, use the package's unit-test script and no `TWENTY_API_URL` override is needed.
@@ -0,0 +1,47 @@
# Workflows
Use when the app needs to ship a manual workflow on install, or when a logic function should be invocable from the workflow builder.
See `logic.md` for post-install hooks. See `app-structure.md` for source layout.
## Mental Model
Twenty workflows are workspace records, not app entities — no `define*` primitive. Ship them via `definePostInstallLogicFunction` and the workspace API.
A workflow is a `Workflow` plus at least one `WorkflowVersion`. Creating a `Workflow` auto-creates its draft `v1`; never create a `WorkflowVersion` directly.
## Manual Record Trigger
- Trigger type: manual record selection.
- The selected record is the trigger payload — no wrapping `record` field.
- Reference fields with `{{trigger.<field>}}`. Do not use `{{trigger.record.<field>}}`.
## Lifecycle
Always: create → configure draft → activate. Never publish a draft directly.
1. Find or create the `Workflow` by stable name or slug. `createWorkflow` produces the draft `v1`.
2. Set the trigger on the draft via `updateWorkflowVersion`.
3. Add steps via the workflow-step mutations. `createWorkflowVersionStep` returns a `stepsDiff`, not the step — read the diff for the new step id, then call `updateWorkflowVersionStep` with the full payload.
4. Activate via `activateWorkflowVersion`.
Forbidden:
- Do not write `Workflow.statuses` — it is computed from the active version's status.
- Do not create `WorkflowVersion` rows directly.
## Idempotency
Find by deterministic name or slug before creating. Single-record queries throw on absence in some Twenty versions — treat not-found as "needs create."
## Permissions
Seeders need workflow settings permissions on the app role. Grant via `defineRole`. If a typed mutation is rejected from the app context, fix the role — falling back to raw GraphQL signals the wrong scope.
## Invoking Locally
```bash
yarn twenty dev:function:exec
```
`yarn twenty dev --once` skips install hooks. Run again after rebuilding to verify idempotency.
@@ -0,0 +1,158 @@
# CLI And Sync
Use this reference for Twenty app CLI command behavior, remotes, authentication, sync, validation commands, build, deploy, logs, function execution, and CI/CD troubleshooting.
Use `../develop-app/app-structure.md` only for app file layout and post-entity-edit validation checklists.
## App Checks
Before running operational commands, confirm the current directory is a Twenty app and inspect the active scripts and remotes:
```bash
test -f package.json
test -f src/application-config.ts
sed -n '1,220p' package.json
yarn twenty remote:list
```
Treat deploys, uninstalls, production remote changes, and production syncs as externally visible actions. Ask for explicit confirmation before running them when the target is production or user data could be affected.
## Validation Commands
Use these commands to validate an app after changes:
```bash
yarn twenty dev:typecheck
yarn lint
yarn twenty dev --once
```
`yarn twenty dev:typecheck` checks generated app types and TypeScript compatibility. `yarn lint` checks local lint rules. `yarn twenty dev --once` performs a bounded build/sync against the active remote.
If a validation command fails because of entity definitions, switch to `develop-app` for the fix. If it fails because of remotes, authentication, build tooling, sync, logs, deploys, or CI/CD, stay in `manage-app`.
## Remotes
A remote is a Twenty server that the app can sync or deploy to. Credentials are stored locally in `~/.twenty/config.json`.
Common commands:
```bash
# Add a remote interactively.
yarn twenty remote:add
# Connect to a local Twenty server.
yarn twenty remote:add --local
# Add a remote non-interactively.
yarn twenty remote:add --url https://your-twenty-server.com --api-key $TWENTY_API_KEY --as production
# List configured remotes.
yarn twenty remote:list
# Switch the active remote.
yarn twenty remote:use <name>
```
When the user says "prod", "production", or "workspace de prod", identify the target remote before syncing or deploying. If it is missing, add it with `--as production` or the user-provided name.
## Development Sync
Always use one-shot sync to synchronize app changes with the active remote:
```bash
yarn twenty dev --once
```
Do not use bare `yarn twenty dev` (watch mode). Run `yarn twenty dev --once` each time changes need to be synced.
One-shot sync requires an authenticated remote. If authentication fails, re-add or switch the remote before retrying.
Use verbose one-shot sync for bounded troubleshooting:
```bash
yarn twenty dev --once --verbose
```
## Troubleshooting
Start by identifying which layer is failing:
- Remote/authentication.
- Dev sync or one-shot sync.
- Build/package generation.
- Deploy/upload/install.
- Runtime function execution.
- CI/CD environment or secrets.
Collect the minimum useful context before changing configuration:
```bash
sed -n '1,220p' package.json
sed -n '1,220p' src/application-config.ts
yarn twenty remote:list
yarn twenty dev --once --verbose
```
For remote or authentication issues:
- Check that the active remote is the intended workspace or server.
- Re-run `yarn twenty remote:add --local` for local app-dev servers.
- Re-run `yarn twenty remote:add --url <url> --as <name>` for remote servers.
- Avoid overwriting a production remote until the target URL is confirmed.
For sync issues:
- Prefer `yarn twenty dev --once --verbose` to get a bounded failure.
- Check generated type or schema errors before editing app entities.
- If the app depends on a changed data model, use `develop-app` to fix the entity definitions.
## Build, Deploy, Logs, And Exec
Build before deploy when the user asks for release readiness or when debugging packaging issues:
```bash
yarn twenty dev:build
```
Publish an app to npm, or publish privately to a configured Twenty server registry:
```bash
yarn twenty app:publish
yarn twenty app:publish --private --remote production
yarn twenty app:install --remote production
```
Before deploying an update, check that `package.json` has a strictly higher semver `version` than the currently deployed version. Re-deploying the same version is rejected. After a private publish, install the deployed version with `yarn twenty app:install` when the target workspace should use it.
Use function logs when debugging runtime behavior on the connected server:
```bash
yarn twenty dev:function:logs
yarn twenty dev:function:logs -n <function-name>
```
Run a logic function manually when testing behavior without its trigger:
```bash
yarn twenty dev:function:exec -n <function-name>
yarn twenty dev:function:exec -n <function-name> -p '{"key":"value"}'
```
Uninstall only after confirmation:
```bash
yarn twenty app:uninstall
yarn twenty app:uninstall --yes
```
Integration tests install and uninstall the app on their target server, so run them against the isolated test instance (`yarn twenty docker:start --test`, port `2021`) rather than the dev instance — see `../develop-app/tests.md`.
## CI/CD
Apps generated with `create-twenty-app` can use GitHub Actions for CI and CD. For deployment automation, check the app's `.github/workflows/` files and configure:
- `TWENTY_DEPLOY_URL`
- `TWENTY_DEPLOY_API_KEY`
Do not put API keys in source files. Use the repository or CI secret store.
@@ -0,0 +1,212 @@
# Prepare App Listing
Use this reference for the publish-facing documentation and visuals of a Twenty app. It complements `create-app`; read this when the app is ready for packaging, marketplace listing, npm publishing, private sharing, or user-facing review.
## When To Use
Use this when the user asks to:
- Write or improve a Twenty app `README.md`.
- Prepare npm package content or Twenty marketplace copy.
- Add or audit `defineApplication()` marketplace metadata.
- Create, select, capture, or validate app logo and screenshots.
- Decide what belongs in `public/` for marketplace, front components, or logic functions.
- Make the app understandable to admins, workspace members, and reviewers before publish/deploy.
## Source Rules
Twenty marketplace metadata comes from `defineApplication()`:
- `displayName`
- `description`
- `author`
- `category`
- `logoUrl`
- `screenshots`
- `aboutDescription`
- `websiteUrl`
- `termsUrl`
- `emailSupport`
- `issueReportUrl`
`logoUrl` and `screenshots` must reference files from the app `public/` folder, for example `public/logo.png` and `public/screenshot-1.png`. If `aboutDescription` is omitted, the marketplace uses the package `README.md` from npm as the About tab content.
Files in `public/` are public, synced in dev mode, included in builds, and served without authentication. Never put secrets, private data, customer records, real tokens, or unreleased confidential material in public assets.
## README Workflow
Before writing, inspect the app:
```bash
sed -n '1,220p' package.json
sed -n '1,220p' src/application-config.ts
find src -maxdepth 3 -type f | sort
find public -maxdepth 2 -type f | sort
```
Read app entities to understand the actual product surface:
- Objects and fields define the data model users will see.
- Views, navigation, and page layouts define the first-run experience.
- Logic functions explain automation and side effects.
- Front components explain UI surfaces and interactions.
- Skills and agents explain AI behavior.
- Connection providers explain third-party OAuth setup.
- Roles explain permission scope and data access.
Write the README for the person installing or evaluating the app, not for the original implementer.
## README Shape
Use this structure unless the app already has a stronger local convention:
```markdown
# App Display Name
One-sentence summary of what the app adds to Twenty.
## What It Does
- Concrete user-facing capability.
- Concrete user-facing capability.
- Concrete user-facing capability.
## What It Adds To Twenty
- Objects:
- Fields:
- Views and navigation:
- Page layouts:
- Front components:
- Logic functions:
- Skills and agents:
- Connections:
## Requirements
- Twenty server/version requirement, if any.
- Third-party account or OAuth app requirements, if any.
- Required server variables or application variables.
## Setup
1. Install or deploy the app.
2. Configure required variables.
3. Add any required connections.
4. Open the relevant Twenty view or app settings page.
## Usage
Short workflow with concrete user actions and expected result.
## Development
Commands for local setup, dev sync, tests, build, deploy, and publish.
## Permissions And Data
What the app reads, writes, updates, deletes, and sends to third-party services.
## Support
Support email, issue tracker, docs, terms, or website links.
```
Keep the README accurate and specific. Do not claim the app supports an entity, workflow, permission, integration, or marketplace capability unless it exists in code.
## Copy Guidelines
Use plain product language:
- Lead with the workspace outcome, not the implementation.
- Name the Twenty objects and views users will actually interact with.
- Explain setup variables by their exact names.
- Distinguish server admin setup from workspace member usage.
- For OAuth connections, mention the provider redirect URI: `<SERVER_URL>/apps/oauth/callback`.
- Document permissions in terms of user risk: read, create, update, soft delete, destroy, third-party send.
Avoid:
- Marketing filler.
- Claims about security, compliance, uptime, or certification that are not backed by the app.
- Screenshots or examples containing real customer data.
- API keys, OAuth secrets, tokens, or private server URLs.
## Visual Assets
Put marketplace and runtime visuals in `public/`:
```text
public/
logo.png
screenshot-1.png
screenshot-2.png
screenshot-3.png
```
Use stable, descriptive filenames. Prefer PNG for marketplace visuals. Keep source files only if the project already keeps editable artwork.
Logo guidance:
- Make it legible at small sizes.
- Use simple geometry or product-specific symbolism.
- Avoid text-heavy logos unless the brand requires it.
- Do not use the Twenty logo as the app logo unless the app is officially owned by Twenty and the user intends that.
Screenshot guidance:
- Capture real app surfaces after `yarn twenty dev --once` or watch-mode sync.
- Prefer the most useful user paths: app listing/about page, object index view, record page layout, front component surface, connection settings, or AI skill/agent behavior.
- Use seeded or synthetic data only.
- Hide browser chrome unless it helps explain setup.
- Do not fake product screenshots with generated images. Use generated visuals for logos, diagrams, empty-state art, or concept assets only.
When a visual is generated or edited, store the final bitmap in `public/` and reference it from `defineApplication()`. If the image appears in README markdown, include useful alt text.
## Application Metadata
Update `src/application-config.ts` with marketplace metadata when publishing:
```ts
export default defineApplication({
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
displayName: APP_DISPLAY_NAME,
description: APP_DESCRIPTION,
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
author: 'Your Company',
category: 'Productivity',
logoUrl: 'public/logo.png',
screenshots: [
'public/screenshot-1.png',
'public/screenshot-2.png',
],
websiteUrl: 'https://example.com',
termsUrl: 'https://example.com/terms',
emailSupport: 'support@example.com',
issueReportUrl: 'https://github.com/org/app/issues',
});
```
Only set `aboutDescription` when the marketplace About tab should differ from the npm README. Otherwise keep one source of truth in `README.md`.
If the app requires a specific Twenty server version, set `engines.twenty` in `package.json`. If publishing to npm, add the `twenty-app` keyword.
## Validation
Before considering README and visuals done:
```bash
yarn twenty dev --once
yarn twenty dev:build
```
Then check:
- Every `logoUrl` and `screenshots` path exists in `public/`.
- `README.md` setup steps match actual variables, connections, and commands.
- `package.json` has `keywords: ["twenty-app"]` when the app is intended for npm marketplace publishing.
- `defineApplication()` metadata does not reference placeholder URLs, support addresses, screenshots, or terms.
- Screenshots are current, readable, and free of secrets or real personal data.
- Public assets render where they are used by front components, logic functions, README, or marketplace metadata.
If visual verification matters, run the app in a browser and capture fresh screenshots instead of relying on stale files.
@@ -0,0 +1,186 @@
# Retrieve Workspace Data
## Overview
Retrieve the Twenty records needed to answer the user's question, then present them as a useful answer, not as raw API output. Always translate technical fields, timestamps, IDs, and nested structures into readable summaries that help the user scan, compare, and act.
## Retrieval Workflow
Resolve the intended workspace before selecting a Twenty MCP server:
- If the user names a workspace, host, MCP server, or URL, use only the matching Twenty MCP namespace or server.
- If multiple Twenty MCP namespaces or configured servers are available and the intended workspace is ambiguous, ask one concise clarifying question before retrieving data.
- If exactly one Twenty MCP namespace is available and the user did not specify a workspace, use it and mention which workspace or server was used when reporting results.
- If a configured server exists but the matching MCP tools are not visible in the current thread, use the MCP setup troubleshooting workflow in `setup.md` instead of falling back to a different workspace.
- Before querying workspace data, confirm the callable Twenty MCP namespace or server name corresponds to the intended workspace whenever there is any ambiguity.
Use the selected connected Twenty MCP server when it is available:
```text
get_tool_catalog -> learn_tools -> execute_tool
```
- Discover object, field, filter, and sort names before querying.
- Retrieve only answer, ordering, and disambiguation fields.
- For "latest", "most recent", or "recent" requests, show the timestamp used for sorting.
- Limit broad lists and state how many records are shown.
- Ask one clarifying question only when tools cannot supply required context.
- If no Twenty MCP tools are available, use the setup workflow in `setup.md`; do not invent workspace data.
## Workspace Origin
Record links need a workspace origin, such as `https://example.twenty.com` or `http://workspace.localhost:3001`.
- If the user provides the workspace URL, use that origin after removing any trailing `/mcp`.
- If the selected MCP server URL is visible, derive the origin from it by removing the trailing `/mcp`.
- If the selected MCP server is configured locally but the URL is not in context, inspect that exact server configuration before formatting linked records.
- If the origin is still unknown, do not invent a hostname. Explain that direct record links need the workspace URL.
## Response Shape
Start with the answer or count, then show the records in the clearest compact shape:
- For one record, use a short labelled summary.
- For two to ten comparable records, use a Markdown table.
- For larger sets, show the most relevant rows first, mention the total, and offer the next useful filter or page only when needed.
- For nested records, summarize the important nested values instead of dumping JSON.
- When comparing records across workspaces, prefer one combined table with a `Workspace` column if it improves scanning. Use separate sections only when each workspace needs different columns.
Use English labels and prose. Keep user-provided names, record values, emails, URLs, and proper nouns unchanged.
## Record Links
Link records back to their original Twenty context whenever the workspace origin and record identity are known.
- Build record links with the Twenty show-page path: `/object/:objectNameSingular/:objectRecordId`.
- For absolute links, combine the workspace origin with that path: `{workspaceOrigin}/object/{objectNameSingular}/{recordId}`.
- Preserve the workspace scheme and port for local workspaces, for example `http://workspace.localhost:3001/object/person/record-id`.
- Use `recordReferences` from MCP responses when available to get `objectNameSingular`, `recordId`, and `displayName`.
- If `recordReferences` is missing, use the record's `id` and the object name from the tool that returned it.
- If `recordReferences` and workspace origin are both available, the first record-name column or record heading MUST link the display name. Do not output unlinked record names in that case.
- Prefer linking the record display name in tables and summaries instead of adding a raw ID column.
- When showing records from multiple workspaces, generate links with each record's own workspace origin.
- If the workspace origin is unknown, do not invent a hostname. Add a compact `Record` column with the object name and record ID, or say that direct links need the workspace URL.
## Visual Identifiers
Add a small visual identifier next to records when it improves scanning and the source data provides one.
- Twenty AI chat currently renders Markdown with `react-markdown` and `remark-gfm`, without raw HTML rendering. Do not rely on HTML such as `<img width="16" height="16">`.
- Use standard Markdown image syntax only: `![alt](image-url)`.
- The current Twenty AI chat image CSS preserves intrinsic image size with `height: auto` and only caps `max-width`. Do not place full-size photos or large avatars in tables unless the image URL is already a small thumbnail.
- Prefer the record's own `avatarUrl`, logo, or image field when present and non-empty.
- For People, show the person's avatar or profile image only when the URL is known to be a small thumbnail. If there is no suitable image, keep the linked name and do not generate fake headshots.
- For Companies, Workspaces, domains, or records mainly identified by an email/domain, prefer a small favicon or logo when the record provides a safe public website, domain, or avatar URL.
- Derive a domain from `emails.primaryEmail`, `website`, `domainName`, or equivalent fields only for display or favicon lookup; never expose private internal domains as external image requests.
- If image sizing is not reliable, use a text `Domain`, `Company`, or `Source` column instead of an image.
- Always include readable text next to the image. Never make an image the only record label.
- If no trustworthy image or favicon source is available, omit the image instead of showing a broken placeholder.
- Avoid adding images to very large result sets unless the user asks for a visual scan.
- Avoid stacking multiple visual tokens before a record name, such as favicon plus emoji plus linked text. Redundant icons make table alignment harder to scan.
## Dates And Times
Never expose ISO/RFC3339 timestamps as the main date display.
- Parse common technical formats such as `2026-05-05T09:43:18.123Z`, `2026-05-05T09:43:18+02:00`, Unix seconds, and Unix milliseconds.
- Convert instants with `Z` or an explicit offset to the user's timezone when known. If timezone is unknown, keep the source timezone or ask only when it changes the meaning.
- Preserve date-only values as dates. Do not shift date-only values across timezones.
- Display absolute dates. Use relative words such as "today", "yesterday", or "last week" only as a supplement when helpful.
- Include the year unless it is truly redundant in a small same-year table.
- Show seconds and milliseconds only when they matter for debugging, audit logs, or ordering events with near-identical times.
Examples:
- Timestamp: `2026-05-05T09:43:18.123Z` -> `May 5, 2026, 11:43 AM`
- Date-only value: `2026-05-05` -> `May 5, 2026`
If the exact raw timestamp is relevant, put it after the readable value:
```text
Created: May 5, 2026, 11:43 AM (raw: 2026-05-05T09:43:18.123Z)
```
## Field Labels
Convert raw field names into user-facing labels:
- `createdAt` -> `Created`
- `updatedAt` -> `Last updated`
- `deletedAt` -> `Deleted`
- `createdBy` -> `Created by`
- `workspaceMemberId` -> `Workspace member`
- `opportunityStage` -> `Opportunity stage`
Prefer the label users see in Twenty when it is available from metadata. Otherwise, split camelCase, snake_case, and kebab-case into normal words.
## Value Formatting
Format values by meaning:
- Empty or null: `Not set`, or omit if the field is irrelevant.
- Booleans: `Yes` / `No`.
- Money: include currency and grouping, for example `EUR 12,450` or `USD 12,450` based on the record currency.
- Percentages: use `%`, round only enough to stay meaningful.
- URLs and emails: make them clickable Markdown links when useful.
- IDs and UUIDs: hide by default unless the user asks for identifiers, deduplication, debugging, or exact references.
- Arrays: show the count and the most important names, not the full serialized array.
## Record Ordering
When the user asks for "latest", "recent", or "last records":
- State which date field was used when it is not obvious, for example `sorted by Last updated`.
- Prefer `updatedAt` for "recent activity" and `createdAt` for "newest records" unless the user's wording or object semantics points to another date.
- Display the chosen date column in readable form.
- If multiple records share the same date, keep a deterministic secondary order such as name or ID.
## Table Alignment
Make tables easy to scan before making them visually decorative.
- Use Markdown alignment markers intentionally: text columns left-aligned (`:---`), numeric money/count columns right-aligned (`---:`), and short status columns centered only when that actually improves scanning (`:---:`).
- Keep record names on a stable left edge. If rows have favicons, avatars, or logos, prefer a dedicated narrow `Icon`, `Logo`, or `Avatar` column followed by a linked record-name column.
- If the table is compact and the image is known to be consistently small, it is acceptable to put `![alt](url) [Name](record-url)` in one cell. Do not also add emoji or extra symbols before the name.
- Keep fixed-format fields such as `Created`, `Updated`, `Amount`, and `Source` to the right of variable-width fields such as `Name`, `Company`, `Person`, and `Domain`.
- Use a consistent date format within a table so rows line up visually, for example `May 5, 2026, 11:43 AM` or `May 5, 11:43`.
- Prefer natural links over extra link columns: link the record name to Twenty, and link the domain or email only when that external destination is useful.
- Avoid raw ID columns in normal user-facing tables. IDs are long, visually dominant, and destroy alignment unless the user asks for them.
## Markdown Patterns
Use a compact table for comparable records:
```markdown
I found 5 recent opportunities, sorted by last updated date.
| Logo | Name | Stage | Amount | Last updated |
| :---: | :--- | :--- | ---: | :--- |
| ![Acme icon](https://example.com/favicon.ico) | [Acme renewal](https://example.twenty.com/object/opportunity/record-id-1) | Negotiation | EUR 12,450 | May 5, 2026, 11:43 AM |
| ![Globex icon](https://globex.example/favicon.ico) | [Globex expansion](https://example.twenty.com/object/opportunity/record-id-2) | Discovery | EUR 8,000 | May 4, 2026, 4:10 PM |
```
For recent companies with `recordReferences`, link the company name:
```markdown
I found 5 recent companies, sorted by Created.
| Company | Domain | Created |
| :--- | :--- | :--- |
| [Acme](https://workspace.example/object/company/00000000-0000-0000-0000-000000000001) | [acme.example](https://acme.example) | May 5, 2026, 11:43 AM |
```
Use a labelled block for one important record:
```markdown
**[Acme renewal](https://example.twenty.com/object/opportunity/record-id-1)**
- Stage: Negotiation
- Amount: EUR 12,450
- Next action: Not set
- Last updated: May 5, 2026, 11:43 AM
```
## Raw Data Exceptions
Show raw JSON, raw timestamps, internal IDs, or full nested objects only when the user asks for debugging, export, exact API payloads, schema inspection, or reproducible commands. Even then, put a readable summary before the raw block.
@@ -0,0 +1,161 @@
# Set Up Twenty MCP
## Overview
Use this reference to configure a workspace-specific Twenty MCP endpoint. The plugin must not assume a fixed workspace domain; collect the user's workspace URL first, normalize it to an MCP URL, then configure and authenticate the MCP client.
## Required User Input
Ask for the workspace URL or host if it is missing. Do not invent or reuse a private default domain.
Accept any of these forms:
```text
myworkspace.twenty.com
myworkspace.customdomain.com
myworkspace.localhost:3001
https://myworkspace.twenty.com
https://myworkspace.customdomain.com/mcp
http://myworkspace.localhost:3001/mcp
```
Optional inputs:
- MCP server name. Default to a workspace-derived name in the form `twenty-<host-without-tld>`, for example `acme.example.com` becomes `twenty-acme-example`.
- Whether to force CLI OAuth login immediately. Default to no in Codex, because Codex can open OAuth automatically when it sees the new MCP server. Only force login if the automatic OAuth window does not appear.
Important OAuth guard:
- Do not pass `--login` from Codex. It is deprecated.
- Do not manually run `open <authorization-url>` for an OAuth URL printed by `codex mcp add` unless the user confirms no browser tab opened. Opening the same authorization URL twice creates two callback tabs with the same `state` and different one-time `code` values.
## URL Normalization
Normalize workspace input as follows:
- If the input already starts with `http://` or `https://`, preserve that scheme.
- If the input has no scheme and is `localhost`, `127.x.x.x`, `[::1]`, `*.localhost`, or `*.localhost:<port>`, use `http://`.
- If the input has no scheme and is any other host, use `https://`.
- If the final URL does not end with `/mcp`, append `/mcp`.
Examples:
```text
myworkspace.twenty.com -> https://myworkspace.twenty.com/mcp name: twenty-myworkspace
acme.example.com -> https://acme.example.com/mcp name: twenty-acme-example
myworkspace.customdomain.com -> https://myworkspace.customdomain.com/mcp name: twenty-myworkspace-customdomain
myworkspace.localhost:3001 -> http://myworkspace.localhost:3001/mcp name: twenty-myworkspace-localhost-3001
```
## Setup Workflow
Use the bundled helper from the Twenty repo or plugin checkout:
```bash
bash packages/twenty-codex-plugin/scripts/setup-mcp.sh <workspace-url-or-mcp-url>
```
Use `--name <server-name>` when configuring multiple Twenty workspaces:
```bash
bash packages/twenty-codex-plugin/scripts/setup-mcp.sh --name twenty-prod acme.twenty.com
bash packages/twenty-codex-plugin/scripts/setup-mcp.sh --name twenty-local acme.localhost:3001
```
Use `--force-login` only when running from a normal terminal and Codex did not open OAuth automatically:
```bash
bash packages/twenty-codex-plugin/scripts/setup-mcp.sh --force-login acme.twenty.com
```
Use `--print-url` to preview normalization without changing client config:
```bash
bash packages/twenty-codex-plugin/scripts/setup-mcp.sh --print-url myworkspace.localhost:3001
```
The helper:
- Normalizes the URL.
- Derives a workspace-specific default MCP server name unless `--name` is provided.
- Replaces any existing MCP server with the same name.
- Runs `codex mcp add <name> --url <normalized-url>`.
- Prints the login command if Codex does not open OAuth automatically.
- Runs `codex mcp login <name>` only when `--force-login` is provided outside a Codex-managed shell.
## Manual Fallback
If the helper is unavailable, configure Codex manually:
```bash
codex mcp add twenty-myworkspace --url https://myworkspace.twenty.com/mcp
codex mcp login twenty-myworkspace
```
For local development:
```bash
codex mcp add twenty-local --url http://myworkspace.localhost:3001/mcp
codex mcp login twenty-local
```
## Validation
After setup, verify the MCP server is available:
```bash
codex mcp get <server-name>
```
When connected, use the Twenty MCP discovery flow:
```text
get_tool_catalog -> learn_tools -> execute_tool
```
## Troubleshooting
### Debugging Workflow
Use this workflow when the user reports missing tools, unexpected workspace data, authentication failures, or suspects Codex queried the wrong Twenty workspace.
1. Identify the intended workspace URL or host from the user's request, using Required User Input when it is missing.
2. Normalize the intended workspace to its MCP URL:
```bash
bash packages/twenty-codex-plugin/scripts/setup-mcp.sh --print-url <workspace-url-or-mcp-url>
```
3. Inspect configured Twenty MCP servers and compare their URLs to the intended normalized URL:
```bash
codex mcp list
codex mcp get <server-name>
```
4. If no configured server points to the intended MCP URL, configure one with a workspace-specific name:
```bash
bash packages/twenty-codex-plugin/scripts/setup-mcp.sh --name <server-name> <workspace-url-or-mcp-url>
```
5. If the server exists but authentication fails or MCP startup reports `Auth required`, run OAuth for that exact server:
```bash
codex mcp login <server-name>
```
6. If the configured server is correct and authenticated but its tools are not visible in the current Codex thread, explain that the thread may have loaded tools before the server was added or authenticated. Ask the user to refresh/reload Codex, start a new thread, or run a fresh `codex exec` query that loads the updated MCP config.
7. Before querying workspace data, confirm the callable Twenty MCP namespace or tool name corresponds to the intended server. If only a different Twenty server is callable, do not use it as a fallback unless the user explicitly asks.
8. When reporting results, state which workspace URL or MCP server was used if there was any ambiguity.
### Common Failure Modes
- If OAuth fails for a self-hosted workspace, check that `SERVER_URL` matches the public workspace origin. OAuth metadata and MCP URLs derive from that value.
- If a local workspace fails over HTTPS, use `http://` explicitly or rely on the helper's localhost default.
- If the server name already exists, the helper replaces that MCP entry with the new URL. Use a custom `--name` to keep multiple workspaces.
### Safety Rules
- Do not put API keys in plugin files. Prefer OAuth login. If an MCP client does not support OAuth, configure bearer headers only in the user's private MCP client config.
- Do not inspect or manually extract OAuth tokens as a workaround.
@@ -0,0 +1,255 @@
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const PLUGIN_ROOT = path.resolve(__dirname, '..', '..');
const PLUGIN_JSON_PATH = path.join(PLUGIN_ROOT, '.codex-plugin', 'plugin.json');
const PACKAGE_JSON_PATH = path.join(PLUGIN_ROOT, 'package.json');
const MCP_JSON_PATH = path.join(PLUGIN_ROOT, '.mcp.json');
const MARKETPLACE_TEMPLATE_PATH = path.join(PLUGIN_ROOT, 'templates', 'marketplace.example.json');
const metadata = require('../validators/metadata');
const assets = require('../validators/assets');
const skills = require('../validators/skills');
const references = require('../validators/references');
const crossDocContracts = require('../validators/cross-doc-contracts');
const setupHelper = require('../validators/setup-helper');
const collectFailures = (assertion) => {
const failures = [];
assertion((message) => failures.push(message));
return failures;
};
const withFileMutation = (filePath, mutator, body) => {
const original = fs.readFileSync(filePath, 'utf8');
try {
fs.writeFileSync(filePath, mutator(original));
body();
} finally {
fs.writeFileSync(filePath, original);
}
};
const withJsonMutation = (filePath, mutator, body) =>
withFileMutation(
filePath,
(original) => {
const data = JSON.parse(original);
mutator(data);
return `${JSON.stringify(data, null, 2)}\n`;
},
body,
);
const withExtraFile = (filePath, contents, body) => {
try {
fs.writeFileSync(filePath, contents);
body();
} finally {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
}
};
// ---------------------------------------------------------------------------
// Smoke tests — every assertion should pass on the current plugin state.
// ---------------------------------------------------------------------------
test('assertJsonMetadata passes on current state', () => {
assert.deepStrictEqual(collectFailures(metadata.assertJsonMetadata), []);
});
test('assertNoBundledMcpConfig passes on current state', () => {
assert.deepStrictEqual(collectFailures(metadata.assertNoBundledMcpConfig), []);
});
test('assertInterfaceFields passes on current state', () => {
assert.deepStrictEqual(collectFailures(metadata.assertInterfaceFields), []);
});
test('assertMarketplaceTemplate passes on current state', () => {
assert.deepStrictEqual(collectFailures(metadata.assertMarketplaceTemplate), []);
});
test('assertAssets passes on current state', () => {
assert.deepStrictEqual(collectFailures(assets.assertAssets), []);
});
test('assertSkills passes on current state', () => {
assert.deepStrictEqual(collectFailures(skills.assertSkills), []);
});
test('assertSkillTriggerPhrases passes on current state', () => {
assert.deepStrictEqual(collectFailures(skills.assertSkillTriggerPhrases), []);
});
test('assertNoLegacySkillReferences passes on current state', () => {
assert.deepStrictEqual(collectFailures(skills.assertNoLegacySkillReferences), []);
});
test('assertReferences passes on current state', () => {
assert.deepStrictEqual(collectFailures(references.assertReferences), []);
});
test('assertHowAppsWork passes on current state', () => {
assert.deepStrictEqual(collectFailures(references.assertHowAppsWork), []);
});
test('assertTwentyMcpFormattingContract passes on current state', () => {
assert.deepStrictEqual(collectFailures(crossDocContracts.assertTwentyMcpFormattingContract), []);
});
test('assertFrontComponentGuidance passes on current state', () => {
assert.deepStrictEqual(collectFailures(crossDocContracts.assertFrontComponentGuidance), []);
});
test('assertCliGuidanceSplit passes on current state', () => {
assert.deepStrictEqual(collectFailures(crossDocContracts.assertCliGuidanceSplit), []);
});
test('assertTestingGuidance passes on current state', () => {
assert.deepStrictEqual(collectFailures(crossDocContracts.assertTestingGuidance), []);
});
test('assertSetupHelper passes on current state', () => {
assert.deepStrictEqual(collectFailures(setupHelper.assertSetupHelper), []);
});
// ---------------------------------------------------------------------------
// Negative cases — each assertion catches its targeted failure.
// ---------------------------------------------------------------------------
test('assertJsonMetadata catches version mismatch between package.json and plugin.json', () => {
withJsonMutation(PACKAGE_JSON_PATH, (pkg) => { pkg.version = '99.99.99'; }, () => {
const failures = collectFailures(metadata.assertJsonMetadata);
assert.ok(
failures.some((f) => f.includes('version must match')),
`expected version-mismatch failure, got: ${failures.join('; ')}`,
);
});
});
test('assertJsonMetadata catches missing .mcp.json from package.json files', () => {
withJsonMutation(PACKAGE_JSON_PATH, (pkg) => {
pkg.files = pkg.files.filter((f) => f !== '.mcp.json');
}, () => {
const failures = collectFailures(metadata.assertJsonMetadata);
assert.ok(failures.some((f) => f.includes('.mcp.json')));
});
});
test('assertJsonMetadata catches non-canonical MCP server', () => {
withJsonMutation(MCP_JSON_PATH, (mcp) => {
mcp.mcpServers['rogue-server'] = { url: 'https://example.com/mcp' };
}, () => {
const failures = collectFailures(metadata.assertJsonMetadata);
assert.ok(failures.some((f) => f.includes('twenty-docs')));
});
});
test('assertNoBundledMcpConfig catches a bundled .app.json', () => {
const stub = path.join(PLUGIN_ROOT, '.app.json');
withExtraFile(stub, '{}', () => {
const failures = collectFailures(metadata.assertNoBundledMcpConfig);
assert.ok(failures.some((f) => f.includes('app declarations must not be shipped')));
});
});
test('assertNoBundledMcpConfig catches a non-placeholder URL', () => {
const stub = path.join(PLUGIN_ROOT, 'scratch-url-check.md');
withExtraFile(stub, 'see https://internal.private-domain.test/secret for details', () => {
const failures = collectFailures(metadata.assertNoBundledMcpConfig);
assert.ok(failures.some((f) => f.includes('non-placeholder URL')));
});
});
test('assertNoBundledMcpConfig catches a bearer token', () => {
const stub = path.join(PLUGIN_ROOT, 'scratch-bearer.md');
withExtraFile(stub, 'Authorization: Bearer abc123def456ghi789jkl012mno', () => {
const failures = collectFailures(metadata.assertNoBundledMcpConfig);
assert.ok(failures.some((f) => f.includes('bearer token')));
});
});
test('assertInterfaceFields catches invalid brandColor', () => {
withJsonMutation(PLUGIN_JSON_PATH, (j) => { j.interface.brandColor = 'red'; }, () => {
const failures = collectFailures(metadata.assertInterfaceFields);
assert.ok(failures.some((f) => f.includes('brandColor must match')));
});
});
test('assertInterfaceFields catches too-long shortDescription', () => {
withJsonMutation(PLUGIN_JSON_PATH, (j) => { j.interface.shortDescription = 'x'.repeat(100); }, () => {
const failures = collectFailures(metadata.assertInterfaceFields);
assert.ok(failures.some((f) => f.includes('shortDescription must be 64')));
});
});
test('assertInterfaceFields catches unknown category', () => {
withJsonMutation(PLUGIN_JSON_PATH, (j) => { j.interface.category = 'Photography'; }, () => {
const failures = collectFailures(metadata.assertInterfaceFields);
assert.ok(failures.some((f) => f.includes('category must be one of')));
});
});
test('assertInterfaceFields catches invalid capability', () => {
withJsonMutation(PLUGIN_JSON_PATH, (j) => { j.interface.capabilities = ['Magic']; }, () => {
const failures = collectFailures(metadata.assertInterfaceFields);
assert.ok(failures.some((f) => f.includes('capabilities contains invalid value')));
});
});
test('assertInterfaceFields catches empty defaultPrompt', () => {
withJsonMutation(PLUGIN_JSON_PATH, (j) => { j.interface.defaultPrompt = []; }, () => {
const failures = collectFailures(metadata.assertInterfaceFields);
assert.ok(failures.some((f) => f.includes('defaultPrompt')));
});
});
test('assertAssets catches a missing screenshot reference', () => {
withJsonMutation(PLUGIN_JSON_PATH, (j) => {
j.interface.screenshots = ['./assets/screenshots/nonexistent.png'];
}, () => {
const failures = collectFailures(assets.assertAssets);
assert.ok(failures.some((f) => f.includes('screenshots entry is missing')));
});
});
test('assertAssets catches a non-PNG logo', () => {
withJsonMutation(PLUGIN_JSON_PATH, (j) => { j.interface.logo = './assets/twenty-logo.svg'; }, () => {
const failures = collectFailures(assets.assertAssets);
assert.ok(failures.some((f) => f.includes('logo must be a PNG')));
});
});
test('assertMarketplaceTemplate catches version drift', () => {
withJsonMutation(MARKETPLACE_TEMPLATE_PATH, (t) => {
t.plugins[0].version = '0.0.0';
}, () => {
const failures = collectFailures(metadata.assertMarketplaceTemplate);
assert.ok(failures.some((f) => f.includes('version must match')));
});
});
test('assertSkillTriggerPhrases catches a SKILL.md missing the When To Use section', () => {
const skillPath = path.join(PLUGIN_ROOT, 'skills', 'create-app', 'SKILL.md');
withFileMutation(skillPath, (original) => original.replace(/^#+\s+When To Use[\s\S]*?(?=\n#\s)/m, ''), () => {
const failures = collectFailures(skills.assertSkillTriggerPhrases);
assert.ok(failures.some((f) => f.includes('create-app') && f.includes('When To Use')));
});
});
test('assertTestingGuidance catches missing manage-app test target instructions', () => {
const skillPath = path.join(PLUGIN_ROOT, 'skills', 'manage-app', 'SKILL.md');
withFileMutation(skillPath, (original) => original.replace('TWENTY_API_URL=http://localhost:2021 yarn test', 'yarn test'), () => {
const failures = collectFailures(crossDocContracts.assertTestingGuidance);
assert.ok(
failures.some((f) => f.includes('manage-app/SKILL.md') && f.includes('TWENTY_API_URL')),
`expected manage-app test target failure, got: ${failures.join('; ')}`,
);
});
});
+177
View File
@@ -0,0 +1,177 @@
#!/usr/bin/env bash
set -euo pipefail
name=""
url="${TWENTY_MCP_URL:-}"
force_login="false"
print_url="false"
usage() {
cat <<'EOF'
Usage: setup-mcp.sh [--name server-name] [--force-login] [--print-url] <workspace-url-or-mcp-url>
Examples:
setup-mcp.sh acme.twenty.com
setup-mcp.sh --force-login https://crm.example.com
setup-mcp.sh --name twenty-local acme.localhost:3001
setup-mcp.sh --name twenty-prod https://crm.example.com/mcp
OAuth:
Codex may open OAuth automatically after the server is added.
Use --force-login only if that does not happen.
Environment:
TWENTY_MCP_URL MCP URL to use when no URL argument is provided.
EOF
}
normalize_url() {
local raw="$1"
raw="${raw#"${raw%%[![:space:]]*}"}"
raw="${raw%"${raw##*[![:space:]]}"}"
raw="${raw%/}"
if [[ "$raw" != http://* && "$raw" != https://* ]]; then
if [[ "$raw" == localhost* || "$raw" == 127.* || "$raw" == "[::1]"* || "$raw" == *.localhost || "$raw" == *.localhost:* ]]; then
raw="http://$raw"
else
raw="https://$raw"
fi
fi
if [[ "$raw" != */mcp ]]; then
raw="${raw%/}/mcp"
fi
echo "$raw"
}
derive_name() {
local normalized="$1"
local host="${normalized#http://}"
host="${host#https://}"
host="${host%%/*}"
host="${host#*@}"
host="${host#[}"
host="${host%]}"
local port=""
if [[ "$host" == *:* && "$host" != *:*:* ]]; then
port="${host##*:}"
host="${host%%:*}"
fi
local stem="$host"
if [[ "$stem" == *.twenty.com ]]; then
stem="${stem%.twenty.com}"
elif [[ "$stem" == *.* && "$stem" != localhost && "$stem" != *.localhost ]]; then
stem="${stem%.*}"
fi
if [[ -n "$port" ]]; then
stem="$stem-$port"
fi
stem="$(printf '%s' "$stem" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//; s/-+/-/g')"
if [[ -z "$stem" ]]; then
echo "twenty"
else
echo "twenty-$stem"
fi
}
while [[ $# -gt 0 ]]; do
case "$1" in
--name)
name="${2:-}"
shift 2
;;
--login)
echo "Warning: --login is deprecated and ignored. Codex starts OAuth during MCP setup when needed; use --force-login only as a manual fallback." >&2
shift
;;
--force-login)
force_login="true"
shift
;;
--print-url)
print_url="true"
shift
;;
-h | --help)
usage
exit 0
;;
--)
shift
url="${1:-$url}"
break
;;
-*)
echo "Unknown option: $1" >&2
echo >&2
usage >&2
exit 1
;;
*)
url="$1"
shift
;;
esac
done
if [[ -z "$url" ]]; then
echo "A Twenty workspace URL is required, for example: https://crm.example.com" >&2
echo
usage
exit 1
fi
url="$(normalize_url "$url")"
if [[ -z "$name" ]]; then
name="$(derive_name "$url")"
fi
if [[ "$print_url" == "true" ]]; then
echo "$url"
exit 0
fi
if ! command -v codex >/dev/null 2>&1; then
echo "The codex CLI is required but was not found in PATH." >&2
exit 1
fi
is_codex_managed_shell() {
[[ -n "${CODEX_THREAD_ID:-}" || "${CODEX_INTERNAL_ORIGINATOR_OVERRIDE:-}" == *"Codex"* ]]
}
if codex mcp get "$name" >/dev/null 2>&1; then
codex mcp remove "$name" >/dev/null
fi
codex mcp add "$name" --url "$url"
echo "Configured Twenty MCP:"
echo " name: $name"
echo " url: $url"
echo
if [[ "$force_login" == "true" ]]; then
if is_codex_managed_shell; then
echo "Skipped forced OAuth login because this helper is running inside Codex."
echo "Codex may open OAuth automatically after the MCP server is added."
echo
echo "If no OAuth window opens, run:"
echo " codex mcp login $name"
exit 0
fi
codex mcp login "$name"
else
echo "Next step:"
echo " Codex may open OAuth automatically. If it does not, run:"
echo " codex mcp login $name"
fi
@@ -0,0 +1,39 @@
#!/usr/bin/env node
const metadata = require('./validators/metadata');
const assets = require('./validators/assets');
const skills = require('./validators/skills');
const references = require('./validators/references');
const crossDocContracts = require('./validators/cross-doc-contracts');
const setupHelper = require('./validators/setup-helper');
const failures = [];
const fail = (message) => failures.push(message);
metadata.assertJsonMetadata(fail);
metadata.assertNoBundledMcpConfig(fail);
metadata.assertInterfaceFields(fail);
metadata.assertMarketplaceTemplate(fail);
assets.assertAssets(fail);
skills.assertSkills(fail);
skills.assertSkillTriggerPhrases(fail);
skills.assertNoLegacySkillReferences(fail);
references.assertReferences(fail);
references.assertHowAppsWork(fail);
crossDocContracts.assertTwentyMcpFormattingContract(fail);
crossDocContracts.assertFrontComponentGuidance(fail);
crossDocContracts.assertCliGuidanceSplit(fail);
crossDocContracts.assertTestingGuidance(fail);
setupHelper.assertSetupHelper(fail);
if (failures.length > 0) {
console.error('Twenty Codex plugin validation failed:');
for (const failure of failures) {
console.error(`- ${failure}`);
}
process.exit(1);
}
console.log('Twenty Codex plugin validation passed.');
@@ -0,0 +1,81 @@
const fs = require('node:fs');
const path = require('node:path');
const {
MIN_LOGO_DIMENSION,
readText,
readPngDimensions,
createJsonReaders,
createInterfacePathResolver,
} = require('./lib');
const assertAssets = (fail) => {
const { readJson } = createJsonReaders(fail);
const resolveInterfacePath = createInterfacePathResolver(fail);
const pluginJson = readJson('packages/twenty-codex-plugin/.codex-plugin/plugin.json');
const interfaceMetadata = pluginJson?.interface;
if (!interfaceMetadata) {
return;
}
const logoPath = resolveInterfacePath(interfaceMetadata.logo);
if (logoPath) {
if (!fs.existsSync(logoPath)) {
fail(`interface.logo file is missing: ${interfaceMetadata.logo}`);
} else if (!logoPath.toLowerCase().endsWith('.png')) {
fail(`interface.logo must be a PNG: ${interfaceMetadata.logo}`);
} else {
const dimensions = readPngDimensions(logoPath);
if (!dimensions) {
fail(`interface.logo is not a readable PNG: ${interfaceMetadata.logo}`);
} else if (dimensions.width < MIN_LOGO_DIMENSION || dimensions.height < MIN_LOGO_DIMENSION) {
fail(`interface.logo must be at least ${MIN_LOGO_DIMENSION}x${MIN_LOGO_DIMENSION} (got ${dimensions.width}x${dimensions.height})`);
}
}
}
const composerIconPath = resolveInterfacePath(interfaceMetadata.composerIcon);
if (composerIconPath) {
if (!fs.existsSync(composerIconPath)) {
fail(`interface.composerIcon file is missing: ${interfaceMetadata.composerIcon}`);
} else {
const extension = path.extname(composerIconPath).toLowerCase();
if (!['.png', '.svg'].includes(extension)) {
fail(`interface.composerIcon must be PNG or SVG: ${interfaceMetadata.composerIcon}`);
}
if (extension === '.svg') {
const contents = readText(composerIconPath);
if (!/<svg[\s>]/i.test(contents)) {
fail(`interface.composerIcon SVG must contain an <svg> root element: ${interfaceMetadata.composerIcon}`);
}
}
}
}
if (Array.isArray(interfaceMetadata.screenshots)) {
for (const screenshot of interfaceMetadata.screenshots) {
const screenshotPath = resolveInterfacePath(screenshot);
if (!screenshotPath) {
continue;
}
if (!fs.existsSync(screenshotPath)) {
fail(`interface.screenshots entry is missing: ${screenshot}`);
} else if (!screenshotPath.toLowerCase().endsWith('.png')) {
fail(`interface.screenshots entries must be PNG: ${screenshot}`);
} else if (!readPngDimensions(screenshotPath)) {
fail(`interface.screenshots entry is not a readable PNG: ${screenshot}`);
}
}
}
};
module.exports = { assertAssets };
@@ -0,0 +1,360 @@
const path = require('node:path');
const { PLUGIN_ROOT, readText } = require('./lib');
const assertTwentyMcpFormattingContract = (fail) => {
const skillPath = path.join(PLUGIN_ROOT, 'skills/use-twenty-mcp/SKILL.md');
const resultFormattingPath = path.join(PLUGIN_ROOT, 'references/use-twenty-mcp/result-formatting.md');
const skill = readText(skillPath);
const formatting = readText(resultFormattingPath);
const requiredSkillFragments = [
'# Output Contract',
'If the tool output includes `recordReferences`',
'MUST link each display name back to Twenty',
'{workspaceOrigin}/object/{objectNameSingular}/{recordId}',
'Never show unlinked record names',
];
for (const fragment of requiredSkillFragments) {
if (!skill.includes(fragment)) {
fail(`use-twenty-mcp/SKILL.md is missing formatting contract fragment: ${fragment}`);
}
}
const requiredFormattingFragments = [
'## Workspace Origin',
'derive the origin from it by removing the trailing `/mcp`',
'If `recordReferences` and workspace origin are both available',
'the first record-name column or record heading MUST link the display name',
'For recent companies with `recordReferences`, link the company name',
];
for (const fragment of requiredFormattingFragments) {
if (!formatting.includes(fragment)) {
fail(`result-formatting.md is missing record-link guidance fragment: ${fragment}`);
}
}
};
const assertFrontComponentGuidance = (fail) => {
const developSkillPath = path.join(PLUGIN_ROOT, 'skills/develop-app/SKILL.md');
const layoutPath = path.join(PLUGIN_ROOT, 'references/develop-app/layout.md');
const frontComponentsPath = path.join(PLUGIN_ROOT, 'references/develop-app/front-components.md');
const standalonePagesPath = path.join(PLUGIN_ROOT, 'references/develop-app/standalone-pages.md');
const appStructurePath = path.join(PLUGIN_ROOT, 'references/develop-app/app-structure.md');
const frontComponentUiPath = path.join(PLUGIN_ROOT, 'references/design/front-component-ui.md');
const developSkill = readText(developSkillPath);
const layout = readText(layoutPath);
const frontComponents = readText(frontComponentsPath);
const standalonePages = readText(standalonePagesPath);
const appStructure = readText(appStructurePath);
const frontComponentUi = readText(frontComponentUiPath);
const requiredDevelopSkillFragments = [
'references/develop-app/front-components.md',
'references/develop-app/standalone-pages.md',
'Twenty UI imports',
'Use `layout.md` for placement, `standalone-pages.md` for full-page custom UI, and `front-component-ui.md` for visual design and Twenty UI component selection',
];
for (const fragment of requiredDevelopSkillFragments) {
if (!developSkill.includes(fragment)) {
fail(`develop-app/SKILL.md is missing front component guidance: ${fragment}`);
}
}
const requiredLayoutFragments = [
'## Front Component Widgets',
'frontComponentUniversalIdentifier',
'A `frontComponentId` is not the same value',
'use `front-components.md`',
];
for (const fragment of requiredLayoutFragments) {
if (!layout.includes(fragment)) {
fail(`layout.md is missing front component guidance: ${fragment}`);
}
}
const requiredFrontComponentFragments = [
'# Front Components',
'defineFrontComponent',
'Use `twenty-sdk/front-component`',
'Use `twenty-client-sdk/core` or `twenty-client-sdk/metadata`',
'Use `twenty-sdk/ui` for Twenty UI components',
'Do not import from `twenty-ui` directly',
'ThemeProvider',
'example-sources/twenty-ui-example.front-component.tsx',
'themeCssVariables',
'mocks `twenty-sdk/ui` during manifest extraction',
'A clean typecheck and sync is not runtime verification',
'without a `FrontComponent error`',
'hard refresh',
];
for (const fragment of requiredFrontComponentFragments) {
if (!frontComponents.includes(fragment)) {
fail(`front-components.md is missing runtime guidance: ${fragment}`);
}
}
const requiredStandalonePageFragments = [
'# Standalone Pages',
'custom page content should be rendered through a `FRONT_COMPONENT` widget',
'There does not appear to be a separate public "page body component" API',
"type: 'STANDALONE_PAGE'",
'NavigationMenuItemType.PAGE_LAYOUT',
'PageLayoutTabLayoutMode.CANVAS',
'gridPosition: { row: 0, column: 0, rowSpan: 12, columnSpan: 12 }',
'12 x 12 fill pattern',
'Full-Page Layout Guidance',
'black screen',
'yarn twenty dev --once',
];
for (const fragment of requiredStandalonePageFragments) {
if (!standalonePages.includes(fragment)) {
fail(`standalone-pages.md is missing standalone page guidance: ${fragment}`);
}
}
const requiredAppStructureFragments = [
'yarn twenty dev:typecheck',
'yarn lint',
'yarn twenty dev --once',
];
for (const fragment of requiredAppStructureFragments) {
if (!appStructure.includes(fragment)) {
fail(`app-structure.md is missing validation checklist command: ${fragment}`);
}
}
const requiredUiDesignFragments = [
'# Design Rules',
'Do not use this reference for source files, registration, runtime imports, data access, CLI commands, or browser verification',
'## Twenty UI Defaults',
'Prefer Twenty UI primitives',
'Use `Callout`',
'Use `Button`',
'Use `Tag`, `Status`, `Chip`, `Label`, and `Avatar`',
'Use `themeCssVariables`',
'Design the visible states',
];
for (const fragment of requiredUiDesignFragments) {
if (!frontComponentUi.includes(fragment)) {
fail(`front-component-ui.md is missing design-only guidance: ${fragment}`);
}
}
const forbiddenUiFragments = [
'# Runtime Safety',
'ReactCurrentDispatcher',
'yarn twenty',
'without a `FrontComponent error`',
];
for (const fragment of forbiddenUiFragments) {
if (frontComponentUi.includes(fragment)) {
fail(`front-component-ui.md should stay design-only and not include: ${fragment}`);
}
}
};
const assertCliGuidanceSplit = (fail) => {
const developSkillPath = path.join(PLUGIN_ROOT, 'skills/develop-app/SKILL.md');
const manageSkillPath = path.join(PLUGIN_ROOT, 'skills/manage-app/SKILL.md');
const appStructurePath = path.join(PLUGIN_ROOT, 'references/develop-app/app-structure.md');
const cliAndSyncPath = path.join(PLUGIN_ROOT, 'references/manage-app/cli-and-sync.md');
const developSkill = readText(developSkillPath);
const manageSkill = readText(manageSkillPath);
const appStructure = readText(appStructurePath);
const cliAndSync = readText(cliAndSyncPath);
const requiredDevelopFragments = [
'references/develop-app/app-structure.md',
'yarn twenty dev:add',
'yarn twenty dev --once',
'switch to `manage-app`',
];
for (const fragment of requiredDevelopFragments) {
if (!developSkill.includes(fragment)) {
fail(`develop-app/SKILL.md is missing CLI split guidance: ${fragment}`);
}
}
const requiredManageFragments = [
'references/manage-app/cli-and-sync.md',
'validation command semantics',
'sync modes',
];
for (const fragment of requiredManageFragments) {
if (!manageSkill.includes(fragment)) {
fail(`manage-app/SKILL.md is missing CLI reference guidance: ${fragment}`);
}
}
const requiredAppStructureFragments = [
'# App Structure',
'../manage-app/cli-and-sync.md',
'## Entity Creation',
'## Validation Checklist',
'run lint and typecheck once at the end (not after each individual edit)',
'yarn twenty dev:typecheck',
'yarn lint',
'yarn twenty dev --once',
];
for (const fragment of requiredAppStructureFragments) {
if (!appStructure.includes(fragment)) {
fail(`app-structure.md is missing develop-app structure guidance: ${fragment}`);
}
}
const forbiddenAppStructureFragments = [
'# App Structure And CLI',
'Use watch mode only',
'Use watch mode for interactive development',
'Use one-shot mode for agents',
'yarn twenty dev --once --verbose',
'yarn twenty remote:list',
'Do not run `yarn twenty dev:typecheck`',
'run outside the sandbox',
'incompatible Node and Yarn',
];
for (const fragment of forbiddenAppStructureFragments) {
if (appStructure.includes(fragment)) {
fail(`app-structure.md should not own CLI semantics or forbid post-edit validation: ${fragment}`);
}
}
const requiredDevelopValidationFragments = [
'run lint and typecheck once at the end (not after each individual edit)',
'yarn twenty dev:typecheck',
'yarn lint',
];
for (const fragment of requiredDevelopValidationFragments) {
if (!developSkill.includes(fragment)) {
fail(`develop-app/SKILL.md is missing post-edit validation guidance: ${fragment}`);
}
}
const forbiddenDevelopFragments = [
'Do not run `yarn twenty dev:typecheck`',
'debug the toolchain',
'run outside the sandbox',
];
for (const fragment of forbiddenDevelopFragments) {
if (developSkill.includes(fragment)) {
fail(`develop-app/SKILL.md should not forbid post-edit validation or warn about the sandbox: ${fragment}`);
}
}
const requiredCliFragments = [
'# CLI And Sync',
'yarn twenty dev:typecheck',
'yarn lint',
'yarn twenty dev --once',
'Always use one-shot sync to synchronize app changes with the active remote',
'Do not use bare `yarn twenty dev` (watch mode)',
'yarn twenty dev --once --verbose',
'yarn twenty remote:list',
'yarn twenty dev:build',
'yarn twenty app:publish',
'yarn twenty dev:function:logs',
];
for (const fragment of requiredCliFragments) {
if (!cliAndSync.includes(fragment)) {
fail(`cli-and-sync.md is missing command guidance: ${fragment}`);
}
}
const forbiddenCliFragments = [
'run outside the sandbox',
'incompatible Node and Yarn',
'operations/command-execution.md',
];
for (const fragment of forbiddenCliFragments) {
if (cliAndSync.includes(fragment)) {
fail(`cli-and-sync.md should not warn about the sandbox or reference the removed command-execution.md: ${fragment}`);
}
}
};
const assertTestingGuidance = (fail) => {
const manageSkillPath = path.join(PLUGIN_ROOT, 'skills/manage-app/SKILL.md');
const testsPath = path.join(PLUGIN_ROOT, 'references/develop-app/tests.md');
const cliAndSyncPath = path.join(PLUGIN_ROOT, 'references/manage-app/cli-and-sync.md');
const agentsPath = path.join(PLUGIN_ROOT, 'AGENTS.md');
const manageSkill = readText(manageSkillPath);
const tests = readText(testsPath);
const cliAndSync = readText(cliAndSyncPath);
const agents = readText(agentsPath);
const requiredManageFragments = [
'run tests for my Twenty app',
'references/develop-app/tests.md',
'yarn twenty docker:start --test',
'TWENTY_API_URL=http://localhost:2021 yarn test',
'Do not run integration tests against the dev instance on `http://localhost:2020`',
];
for (const fragment of requiredManageFragments) {
if (!manageSkill.includes(fragment)) {
fail(`manage-app/SKILL.md is missing test execution guidance: ${fragment}`);
}
}
const requiredSharedFragments = [
'isolated test instance',
'port (`2021`)',
'TWENTY_API_URL=http://localhost:2021 yarn test',
'Do not run integration tests against `http://localhost:2020`',
];
for (const fragment of requiredSharedFragments) {
if (!tests.includes(fragment)) {
fail(`tests.md is missing isolated integration-test guidance: ${fragment}`);
}
}
const requiredCliFragments = [
'Integration tests install and uninstall the app on their target server',
'yarn twenty docker:start --test',
'port `2021`',
'../develop-app/tests.md',
];
for (const fragment of requiredCliFragments) {
if (!cliAndSync.includes(fragment)) {
fail(`cli-and-sync.md is missing integration-test target guidance: ${fragment}`);
}
}
const requiredAgentsFragments = [
'TWENTY_API_URL=http://localhost:2021 yarn test',
'Integration tests must target the isolated test instance on port `2021`',
];
for (const fragment of requiredAgentsFragments) {
if (!agents.includes(fragment)) {
fail(`AGENTS.md is missing durable test target guidance: ${fragment}`);
}
}
};
module.exports = {
assertTwentyMcpFormattingContract,
assertFrontComponentGuidance,
assertCliGuidanceSplit,
assertTestingGuidance,
};
@@ -0,0 +1,178 @@
const fs = require('node:fs');
const path = require('node:path');
const PLUGIN_ROOT = path.resolve(__dirname, '..', '..');
const REPO_ROOT = path.resolve(PLUGIN_ROOT, '..', '..');
const PUBLIC_DOCS_MCP_SERVER_NAME = 'twenty-docs';
const PUBLIC_DOCS_MCP_URL = 'https://docs.twenty.com/mcp';
const LEGACY_SKILL_NAMES = [
'app-readme-and-visuals',
'build-app-features',
'create-an-app',
'design-front-components',
'retrieve-and-present-data',
'setup-mcp',
];
const VALID_CAPABILITIES = new Set(['Interactive', 'Read', 'Write']);
const VALID_CATEGORIES = new Set(['Coding', 'Productivity', 'Communication', 'Data', 'Design', 'Marketing', 'Sales']);
const SHORT_DESCRIPTION_MAX = 64;
const MIN_LOGO_DIMENSION = 256;
const readText = (filePath) => fs.readFileSync(filePath, 'utf8');
const listFiles = (directory) => {
const entries = fs.readdirSync(directory, { withFileTypes: true });
const files = [];
for (const entry of entries) {
const absolutePath = path.join(directory, entry.name);
if (entry.isDirectory()) {
files.push(...listFiles(absolutePath));
} else {
files.push(absolutePath);
}
}
return files;
};
const parseSkillFrontmatter = (skillPath) => {
const contents = readText(skillPath);
const match = contents.match(/^---\n([\s\S]*?)\n---\n/);
if (!match) {
return undefined;
}
const frontmatter = {};
for (const line of match[1].split('\n')) {
const fieldMatch = line.match(/^([a-zA-Z0-9_-]+):\s*(.*)$/);
if (fieldMatch) {
frontmatter[fieldMatch[1]] = fieldMatch[2].replace(/^["']|["']$/g, '');
}
}
return frontmatter;
};
const parseQuotedYamlField = (contents, fieldName) => {
const match = contents.match(new RegExp(`^\\s+${fieldName}:\\s+"([^"]+)"\\s*$`, 'm'));
return match?.[1];
};
const isAllowedDocumentationHost = (hostname) => {
const host = hostname.toLowerCase();
return (
host === 'localhost' ||
host.endsWith('.localhost') ||
host.startsWith('127.') ||
host === '[::1]' ||
host === 'example.com' ||
host.endsWith('.example.com') ||
host === 'example.twenty.com' ||
host === 'myworkspace.twenty.com' ||
host === 'myworkspace.customdomain.com' ||
host === 'your-twenty-server.com' ||
host === 'app.twenty.com' ||
host === 'twenty.com' ||
host === 'docs.twenty.com' ||
host === 'www.docker.com' ||
host === 'github.com' ||
host === 'www.w3.org' ||
host === 'developers.openai.com' ||
host === 'keepachangelog.com' ||
host === 'semver.org' ||
host.endsWith('.example')
);
};
const readPngDimensions = (filePath) => {
const buffer = fs.readFileSync(filePath);
const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
if (buffer.length < 24 || !buffer.subarray(0, 8).equals(signature)) {
return undefined;
}
if (buffer.subarray(12, 16).toString('ascii') !== 'IHDR') {
return undefined;
}
return {
width: buffer.readUInt32BE(16),
height: buffer.readUInt32BE(20),
};
};
const createJsonReaders = (fail) => {
const readJson = (relativePath) => {
const absolutePath = path.join(REPO_ROOT, relativePath);
try {
return JSON.parse(readText(absolutePath));
} catch (error) {
fail(`${relativePath} is not valid JSON: ${error.message}`);
return undefined;
}
};
const readOptionalJson = (relativePath) => {
const absolutePath = path.join(REPO_ROOT, relativePath);
if (!fs.existsSync(absolutePath)) {
return undefined;
}
return readJson(relativePath);
};
return { readJson, readOptionalJson };
};
const createInterfacePathResolver = (fail) => (relativePath) => {
if (typeof relativePath !== 'string' || relativePath.length === 0) {
return undefined;
}
if (!relativePath.startsWith('./')) {
fail(`interface path must start with ./ (got: ${relativePath})`);
return undefined;
}
const resolvedPath = path.resolve(PLUGIN_ROOT, relativePath.slice(2));
// Reject ../ traversal that escapes the plugin directory after normalization
if (resolvedPath !== PLUGIN_ROOT && !resolvedPath.startsWith(PLUGIN_ROOT + path.sep)) {
fail(`interface path must stay within the plugin directory (got: ${relativePath})`);
return undefined;
}
return resolvedPath;
};
module.exports = {
PLUGIN_ROOT,
REPO_ROOT,
PUBLIC_DOCS_MCP_SERVER_NAME,
PUBLIC_DOCS_MCP_URL,
LEGACY_SKILL_NAMES,
VALID_CAPABILITIES,
VALID_CATEGORIES,
SHORT_DESCRIPTION_MAX,
MIN_LOGO_DIMENSION,
readText,
listFiles,
parseSkillFrontmatter,
parseQuotedYamlField,
isAllowedDocumentationHost,
readPngDimensions,
createJsonReaders,
createInterfacePathResolver,
};
@@ -0,0 +1,256 @@
const fs = require('node:fs');
const path = require('node:path');
const {
PLUGIN_ROOT,
REPO_ROOT,
PUBLIC_DOCS_MCP_SERVER_NAME,
PUBLIC_DOCS_MCP_URL,
VALID_CAPABILITIES,
VALID_CATEGORIES,
SHORT_DESCRIPTION_MAX,
readText,
listFiles,
isAllowedDocumentationHost,
createJsonReaders,
} = require('./lib');
const assertJsonMetadata = (fail) => {
const { readJson, readOptionalJson } = createJsonReaders(fail);
const packageJson = readJson('packages/twenty-codex-plugin/package.json');
const pluginJson = readJson('packages/twenty-codex-plugin/.codex-plugin/plugin.json');
const mcpJson = readJson('packages/twenty-codex-plugin/.mcp.json');
const marketplaceJson = readOptionalJson('.agents/plugins/marketplace.json');
if (packageJson?.version !== pluginJson?.version) {
fail('package.json version must match .codex-plugin/plugin.json version');
}
if (!packageJson?.files?.includes('.mcp.json')) {
fail('package.json files must include .mcp.json for the public docs MCP server');
}
if (!packageJson?.files?.includes('references')) {
fail('package.json files must include references for shared plugin guidance');
}
if (pluginJson?.mcpServers !== './.mcp.json') {
fail('.codex-plugin/plugin.json must declare mcpServers as ./.mcp.json');
}
const servers = mcpJson?.mcpServers;
if (!servers || typeof servers !== 'object' || Array.isArray(servers)) {
fail('.mcp.json must declare an mcpServers object');
} else {
const serverNames = Object.keys(servers);
if (serverNames.length !== 1 || serverNames[0] !== PUBLIC_DOCS_MCP_SERVER_NAME) {
fail(`.mcp.json must only declare ${PUBLIC_DOCS_MCP_SERVER_NAME}`);
}
const docsServer = servers[PUBLIC_DOCS_MCP_SERVER_NAME];
if (!docsServer || typeof docsServer !== 'object' || Array.isArray(docsServer)) {
fail(`${PUBLIC_DOCS_MCP_SERVER_NAME} must be an object`);
} else {
const docsServerKeys = Object.keys(docsServer);
if (docsServerKeys.length !== 1 || docsServerKeys[0] !== 'url') {
fail(`${PUBLIC_DOCS_MCP_SERVER_NAME} must only declare a url`);
}
if (docsServer.url !== PUBLIC_DOCS_MCP_URL) {
fail(`${PUBLIC_DOCS_MCP_SERVER_NAME} url must be ${PUBLIC_DOCS_MCP_URL}`);
}
}
}
const marketplaceEntry = marketplaceJson?.plugins?.find((entry) => entry.name === 'twenty');
if (marketplaceJson && !marketplaceEntry) {
fail('.agents/plugins/marketplace.json includes plugins but not the twenty plugin entry');
} else if (marketplaceEntry && marketplaceEntry.source?.path !== './packages/twenty-codex-plugin') {
fail('marketplace twenty source path must be ./packages/twenty-codex-plugin');
}
if (fs.existsSync(path.join(REPO_ROOT, 'plugins', 'twenty'))) {
fail('legacy plugins/twenty path must not exist; use packages/twenty-codex-plugin directly');
}
};
const assertNoBundledMcpConfig = (fail) => {
const gitignorePath = path.join(PLUGIN_ROOT, '.gitignore');
if (fs.existsSync(gitignorePath) && readText(gitignorePath).split(/\r?\n/).includes('.mcp.json')) {
fail('packages/twenty-codex-plugin/.gitignore must not ignore the public .mcp.json');
}
for (const filePath of listFiles(PLUGIN_ROOT)) {
const relativePath = path.relative(PLUGIN_ROOT, filePath);
if (relativePath.split(path.sep).includes('__tests__')) {
continue;
}
if (path.basename(filePath) === '.mcp.json' && relativePath !== '.mcp.json') {
fail(`workspace-specific MCP config must not be shipped: ${relativePath}`);
}
if (path.basename(filePath) === '.app.json') {
fail(`app declarations must not be shipped unless intentionally allowed in validation: ${relativePath}`);
}
const contents = readText(filePath);
const urls = contents.matchAll(/https?:\/\/[^\s"`'<>)]*/g);
for (const [rawUrl] of urls) {
let parsedUrl;
if (/[${}*]/.test(rawUrl)) {
continue;
}
try {
parsedUrl = new URL(rawUrl);
} catch {
continue;
}
if (!isAllowedDocumentationHost(parsedUrl.hostname)) {
fail(`non-placeholder URL found in ${relativePath}: ${parsedUrl.origin}`);
}
}
if (/Bearer\s+(?!YOUR_API_KEY\b)[A-Za-z0-9._-]{20,}/.test(contents)) {
fail(`possible bearer token found in ${relativePath}`);
}
if (/sk-[A-Za-z0-9_-]{20,}/.test(contents)) {
fail(`possible API key found in ${relativePath}`);
}
}
};
const assertInterfaceFields = (fail) => {
const { readJson } = createJsonReaders(fail);
const pluginJson = readJson('packages/twenty-codex-plugin/.codex-plugin/plugin.json');
const interfaceMetadata = pluginJson?.interface;
if (!interfaceMetadata || typeof interfaceMetadata !== 'object' || Array.isArray(interfaceMetadata)) {
fail('.codex-plugin/plugin.json must declare an interface object');
return;
}
const requiredStringFields = [
'displayName',
'shortDescription',
'longDescription',
'developerName',
'category',
'websiteURL',
'privacyPolicyURL',
'termsOfServiceURL',
'brandColor',
'logo',
'composerIcon',
];
for (const field of requiredStringFields) {
const value = interfaceMetadata[field];
if (typeof value !== 'string' || value.trim().length === 0) {
fail(`.codex-plugin/plugin.json interface.${field} must be a non-empty string`);
}
}
if (typeof interfaceMetadata.shortDescription === 'string' && interfaceMetadata.shortDescription.length > SHORT_DESCRIPTION_MAX) {
fail(`.codex-plugin/plugin.json interface.shortDescription must be ${SHORT_DESCRIPTION_MAX} characters or fewer`);
}
if (typeof interfaceMetadata.brandColor === 'string' && !/^#[0-9a-fA-F]{6}$/.test(interfaceMetadata.brandColor)) {
fail('.codex-plugin/plugin.json interface.brandColor must match #RRGGBB hex format');
}
if (typeof interfaceMetadata.category === 'string' && !VALID_CATEGORIES.has(interfaceMetadata.category)) {
fail(`.codex-plugin/plugin.json interface.category must be one of: ${[...VALID_CATEGORIES].join(', ')}`);
}
if (!Array.isArray(interfaceMetadata.capabilities) || interfaceMetadata.capabilities.length === 0) {
fail('.codex-plugin/plugin.json interface.capabilities must be a non-empty array');
} else {
for (const capability of interfaceMetadata.capabilities) {
if (!VALID_CAPABILITIES.has(capability)) {
fail(`.codex-plugin/plugin.json interface.capabilities contains invalid value: ${capability}`);
}
}
}
if (!Array.isArray(interfaceMetadata.defaultPrompt) || interfaceMetadata.defaultPrompt.length === 0) {
fail('.codex-plugin/plugin.json interface.defaultPrompt must be a non-empty array of strings');
} else {
for (const prompt of interfaceMetadata.defaultPrompt) {
if (typeof prompt !== 'string' || prompt.trim().length === 0) {
fail('.codex-plugin/plugin.json interface.defaultPrompt entries must be non-empty strings');
}
}
}
if (!Array.isArray(interfaceMetadata.screenshots)) {
fail('.codex-plugin/plugin.json interface.screenshots must be an array (use [] if no screenshots yet)');
}
};
const assertMarketplaceTemplate = (fail) => {
const { readJson, readOptionalJson } = createJsonReaders(fail);
const templatePath = 'packages/twenty-codex-plugin/templates/marketplace.example.json';
const template = readOptionalJson(templatePath);
const pluginJson = readJson('packages/twenty-codex-plugin/.codex-plugin/plugin.json');
if (!template) {
fail(`marketplace template is missing at ${templatePath}`);
return;
}
const entries = template.plugins;
if (!Array.isArray(entries) || entries.length === 0) {
fail(`${templatePath} must declare a non-empty plugins array`);
return;
}
const twentyEntry = entries.find((entry) => entry?.name === 'twenty');
if (!twentyEntry) {
fail(`${templatePath} must include a plugin entry named "twenty"`);
return;
}
if (twentyEntry.version !== pluginJson?.version) {
fail(`${templatePath} twenty.version must match plugin.json version`);
}
if (twentyEntry.source?.path !== './packages/twenty-codex-plugin') {
fail(`${templatePath} twenty.source.path must be ./packages/twenty-codex-plugin`);
}
if (!twentyEntry.policy?.installation) {
fail(`${templatePath} twenty.policy.installation is required`);
}
if (!twentyEntry.policy?.authentication) {
fail(`${templatePath} twenty.policy.authentication is required`);
}
if (twentyEntry.category !== pluginJson?.interface?.category) {
fail(`${templatePath} twenty.category must match plugin.json interface.category`);
}
};
module.exports = {
assertJsonMetadata,
assertNoBundledMcpConfig,
assertInterfaceFields,
assertMarketplaceTemplate,
};
@@ -0,0 +1,99 @@
const fs = require('node:fs');
const path = require('node:path');
const { PLUGIN_ROOT, readText } = require('./lib');
const REQUIRED_REFERENCES = [
'references/design/front-component-ui.md',
'references/develop-app/app-structure.md',
'references/develop-app/data-model.md',
'references/develop-app/front-components.md',
'references/develop-app/logic.md',
'references/develop-app/layout.md',
'references/develop-app/standalone-pages.md',
'references/develop-app/tests.md',
'references/develop-app/workflows.md',
'references/manage-app/cli-and-sync.md',
'references/publish-app/prepare-for-app-store.md',
'references/concepts/how-apps-work.md',
'references/use-twenty-mcp/setup.md',
'references/use-twenty-mcp/result-formatting.md',
];
const assertReferences = (fail) => {
for (const relativePath of REQUIRED_REFERENCES) {
const absolutePath = path.join(PLUGIN_ROOT, relativePath);
if (!fs.existsSync(absolutePath)) {
fail(`required reference is missing: ${relativePath}`);
}
}
};
const assertHowAppsWork = (fail) => {
const howAppsWorkPath = path.join(PLUGIN_ROOT, 'references/concepts/how-apps-work.md');
if (!fs.existsSync(howAppsWorkPath)) {
fail('required reference is missing: references/concepts/how-apps-work.md');
return;
}
const howAppsWork = readText(howAppsWorkPath);
const requiredFragments = [
'# How Twenty Apps Work',
'## What Is A Twenty App',
'standalone npm package',
'## SDK Packages',
'twenty-sdk',
'twenty-client-sdk',
'## Twenty Instances And Remotes',
'## Local Development Environment',
'## App Lifecycle',
'create-twenty-app',
'## Front Component Rendering',
'Remote DOM',
'## App File Structure',
'application-config.ts',
'## Sharing An App',
'## Key Concepts',
'Universal identifiers',
];
for (const fragment of requiredFragments) {
if (!howAppsWork.includes(fragment)) {
fail(`how-apps-work.md is missing foundational guidance: ${fragment}`);
}
}
// use-twenty-mcp is intentionally excluded: it covers consuming the Twenty MCP
// server to retrieve and present workspace records, not building apps, so the
// app-foundations doc (how-apps-work.md) is not a relevant prerequisite for it.
const skillsToCheck = [
'skills/create-app/SKILL.md',
'skills/develop-app/SKILL.md',
'skills/manage-app/SKILL.md',
'skills/publish-app/SKILL.md',
];
for (const skillRelPath of skillsToCheck) {
const skillPath = path.join(PLUGIN_ROOT, skillRelPath);
if (!fs.existsSync(skillPath)) {
fail(`required skill is missing: ${skillRelPath}`);
continue;
}
const skill = readText(skillPath);
if (!skill.includes('references/concepts/how-apps-work.md')) {
fail(`${skillRelPath} must reference how-apps-work.md`);
}
}
};
module.exports = {
REQUIRED_REFERENCES,
assertReferences,
assertHowAppsWork,
};
@@ -0,0 +1,48 @@
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const { PLUGIN_ROOT } = require('./lib');
const URL_NORMALIZATION_CASES = [
['myworkspace.localhost:3001', 'http://myworkspace.localhost:3001/mcp'],
['crm.example.com', 'https://crm.example.com/mcp'],
['https://crm.example.com/mcp', 'https://crm.example.com/mcp'],
];
const assertSetupHelper = (fail) => {
const setupScript = path.join(PLUGIN_ROOT, 'scripts', 'setup-mcp.sh');
const syntaxCheck = spawnSync('bash', ['-n', setupScript], { encoding: 'utf8' });
// spawnSync sets `error` (and leaves status/stdout/stderr null) when bash itself
// cannot be launched — surface that instead of blaming the script's syntax.
if (syntaxCheck.error) {
fail(`could not run bash to validate setup-mcp.sh: ${syntaxCheck.error.message}`);
return;
}
if (syntaxCheck.status !== 0) {
fail(`setup-mcp.sh has invalid bash syntax: ${syntaxCheck.stderr.trim()}`);
}
for (const [input, expected] of URL_NORMALIZATION_CASES) {
const result = spawnSync('bash', [setupScript, '--print-url', input], { encoding: 'utf8' });
if (result.error) {
fail(`could not run bash for setup-mcp.sh --print-url ${input}: ${result.error.message}`);
continue;
}
if (result.status !== 0) {
fail(`setup-mcp.sh --print-url ${input} failed: ${result.stderr.trim()}`);
continue;
}
const actual = result.stdout.trim();
if (actual !== expected) {
fail(`setup-mcp.sh normalized ${input} to ${actual}, expected ${expected}`);
}
}
};
module.exports = { assertSetupHelper };
@@ -0,0 +1,155 @@
const fs = require('node:fs');
const path = require('node:path');
const {
PLUGIN_ROOT,
LEGACY_SKILL_NAMES,
readText,
listFiles,
parseSkillFrontmatter,
parseQuotedYamlField,
} = require('./lib');
const EXPECTED_CANONICAL_SKILLS = [
'create-app',
'develop-app',
'manage-app',
'publish-app',
'use-twenty-mcp',
];
const assertSkills = (fail) => {
const skillsRoot = path.join(PLUGIN_ROOT, 'skills');
const skillDirectories = fs
.readdirSync(skillsRoot, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort();
for (const skillName of EXPECTED_CANONICAL_SKILLS) {
if (!skillDirectories.includes(skillName)) {
fail(`canonical skill is missing: ${skillName}`);
}
}
for (const skillName of LEGACY_SKILL_NAMES) {
if (skillDirectories.includes(skillName)) {
fail(`legacy skill directory must be transferred out of skills/: ${skillName}`);
}
}
for (const skillName of skillDirectories) {
const skillPath = path.join(skillsRoot, skillName, 'SKILL.md');
const agentsPath = path.join(skillsRoot, skillName, 'agents', 'openai.yaml');
if (!fs.existsSync(skillPath)) {
fail(`${skillName} is missing SKILL.md`);
continue;
}
const frontmatter = parseSkillFrontmatter(skillPath);
if (!frontmatter) {
fail(`${skillName}/SKILL.md is missing YAML frontmatter`);
} else {
const frontmatterKeys = Object.keys(frontmatter).sort();
if (frontmatter.name !== skillName) {
fail(`${skillName}/SKILL.md frontmatter name must match its directory`);
}
if (!frontmatter.description) {
fail(`${skillName}/SKILL.md frontmatter description is required`);
}
if (frontmatterKeys.some((key) => !['description', 'name'].includes(key))) {
fail(`${skillName}/SKILL.md frontmatter should only include name and description`);
}
}
if (!fs.existsSync(agentsPath)) {
fail(`${skillName} is missing agents/openai.yaml`);
continue;
}
const agentsYaml = readText(agentsPath);
const displayName = parseQuotedYamlField(agentsYaml, 'display_name');
const shortDescription = parseQuotedYamlField(agentsYaml, 'short_description');
const defaultPrompt = parseQuotedYamlField(agentsYaml, 'default_prompt');
if (!displayName) {
fail(`${skillName}/agents/openai.yaml is missing interface.display_name`);
}
if (!shortDescription) {
fail(`${skillName}/agents/openai.yaml is missing interface.short_description`);
} else if (shortDescription.length > 64) {
fail(`${skillName}/agents/openai.yaml short_description must be 64 characters or fewer`);
}
if (!defaultPrompt) {
fail(`${skillName}/agents/openai.yaml is missing interface.default_prompt`);
} else if (!defaultPrompt.includes(`$${skillName}`)) {
fail(`${skillName}/agents/openai.yaml default_prompt must mention $${skillName}`);
}
}
};
const assertSkillTriggerPhrases = (fail) => {
const skillsRoot = path.join(PLUGIN_ROOT, 'skills');
const skillDirectories = fs
.readdirSync(skillsRoot, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name);
for (const skillName of skillDirectories) {
const skillPath = path.join(skillsRoot, skillName, 'SKILL.md');
if (!fs.existsSync(skillPath)) {
continue;
}
const contents = readText(skillPath);
if (!/^#+\s+When To Use\s*$/m.test(contents)) {
fail(`${skillName}/SKILL.md must include a "When To Use" section with representative trigger phrases`);
}
}
};
const assertNoLegacySkillReferences = (fail) => {
const filesToCheck = listFiles(PLUGIN_ROOT).filter((filePath) => {
const extension = path.extname(filePath);
return ['.md', '.yaml', '.yml'].includes(extension);
});
for (const filePath of filesToCheck) {
const relativePath = path.relative(PLUGIN_ROOT, filePath);
const contents = readText(filePath);
for (const legacySkillName of LEGACY_SKILL_NAMES) {
if (contents.includes(`name: ${legacySkillName}`)) {
fail(`${relativePath} must not declare legacy skill name ${legacySkillName}`);
}
const mentionPattern =
legacySkillName === 'setup-mcp'
? /(^|[^A-Za-z0-9_-])setup-mcp(?!\.sh)(?=$|[^A-Za-z0-9_-])/
: new RegExp(
`(^|[^A-Za-z0-9_-])${legacySkillName}(?=$|[^A-Za-z0-9_-])`,
);
if (mentionPattern.test(contents)) {
fail(`${relativePath} must not mention legacy skill name ${legacySkillName}`);
}
}
}
};
module.exports = {
EXPECTED_CANONICAL_SKILLS,
assertSkills,
assertSkillTriggerPhrases,
assertNoLegacySkillReferences,
};
@@ -0,0 +1,98 @@
---
name: create-app
description: Use when the user wants to create or scaffold a new Twenty app
---
# When To Use
Pick this skill when the user wants to start a brand-new Twenty app from scratch. Representative triggers:
- "I want to build a Twenty app"
- "scaffold a new Twenty app"
- "start a new Twenty plugin / extension / integration"
- "create a CRM extension for Twenty"
- "set up a Twenty app project"
- "bootstrap a Twenty app called X"
Do not use this skill when the app already exists — use `develop-app` to add features, `manage-app` for sync/deploy/troubleshooting, `publish-app` for marketplace prep, or `use-twenty-mcp` to query workspace data.
# Quickstart an App
For background on how Twenty apps work — the SDK packages, remotes, sync lifecycle, and rendering model — read `../../references/concepts/how-apps-work.md`.
Use this as the default way to start an app unless the user gives different instructions.
## Why A Twenty Instance Is Needed
Before scaffolding, explain to the user that a Twenty app is not a standalone application — it is a package that extends a running Twenty instance. During development, the app's entities (objects, views, front components, logic functions) are synced to a Twenty instance where they are registered, rendered, and executed. Without a connected instance, there is nothing to sync to, no workspace to test in, and no way to verify the app works.
## Choose A Twenty Instance
By default, ask the user for the URL of their existing Twenty instance (e.g. `https://app.twenty.com` or a self-hosted URL). Mention that if they don't have one yet, you can spin up a local instance with Docker instead.
The two options are:
1. **Existing Twenty instance (default)** — the user provides the URL of a running Twenty server (self-hosted or cloud, e.g. `https://app.twenty.com`). The scaffolder authenticates via OAuth on that instance. Best when the user already has a workspace with data they want to develop against.
2. **Local instance with Docker (fallback)** — only when the user has no Twenty instance available. The scaffolder starts a disposable local Twenty server on `http://localhost:2020` through Docker. Requires Docker Desktop to be installed and running.
If the user does not provide a URL, ask first whether they have a Twenty instance URL to use; only fall back to Docker if they explicitly say they don't have one.
Before scaffolding, repeat back the app's purpose in one sentence and its expected shape: standard objects extended, any custom objects, whether it needs UI, whether it needs workflows or post-install seeding. Scaffolding is one-way — confirming here avoids re-scaffolds later.
## Scaffolding
First, ask the user for the app name if they did not provide one.
The directory name must contain only lowercase letters, numbers, and hyphens. Transform the entered name to lowercase and replace spaces with hyphens when needed.
For an existing Twenty instance (default):
```bash
npx create-twenty-app@latest <app-name> --url <twenty-instance-url>
```
The `--url` flag authenticates via OAuth on the provided instance. The scaffolder opens a browser for the OAuth flow, then stores the credentials as a remote in `~/.twenty/config.json`.
Only if the user has no Twenty instance and wants to try locally with Docker:
```bash
npx create-twenty-app@latest <app-name>
```
This omits `--url` and starts a disposable local Twenty server through Docker.
The scaffolder handles everything: it creates the project, enables corepack, installs dependencies, initializes Git, authenticates with the target instance, runs an initial sync, and opens the generated app page when possible.
If the user provides a package name, display name, or description, pass them through:
```bash
npx create-twenty-app@latest <app-directory> --name "<package-name>" --display-name "<display-name>" --description "<description>"
```
Supported create-time options are `--name`, `--display-name`, `--description`, `--url`, and `--authentication-method`.
## After Scaffolding
When the scaffolder completes, the app is fully created, synced, and installed. The job is done.
Do not run any follow-up validation commands after scaffolding unless the user asks for them. Do not run `yarn twenty dev --once`, `yarn test`, `yarn lint`, or other validation just to prove the scaffold worked; the scaffolder already performed the initial sync. If the user asks to run tests later, switch to `develop-app` or `manage-app` guidance and run the full suite against the isolated test instance with `TWENTY_API_URL=http://localhost:2021`.
Report to the user that the app was created successfully and is ready for development. Then stop. Wait for the user to ask for the next action.
The scaffolder generates a placeholder page at `src/front-components/main-page.tsx` plus its page layout and navigation menu item. In `develop-app`, delete all three before the first deploy unless the app actually needs UI. Do not stack additional pages on top of the placeholder.
## Docker Fallback Troubleshooting
Use this only when the user opted into the Docker fallback and it fails because Docker is missing or not running.
The preferred recovery is to ask the user for an existing Twenty instance URL and rerun the scaffolder with `--url <twenty-instance-url>` — this skips Docker entirely.
If the user still wants the local Docker path and Docker is missing, share this download link: `https://www.docker.com/products/docker-desktop/` and ask them to install Docker Desktop.
# Next Steps
Only proceed to these when the user explicitly asks:
- Use `develop-app` when the user wants to add objects, fields, logic functions, roles, views, navigation, page layouts, skills, agents, or front component registrations.
- Use `references/design/front-component-ui.md` when the user wants to design or improve the UI of a Twenty front component.
- When the user later makes changes to app entities, use `yarn twenty dev --once` to sync those changes. See the `manage-app` skill for sync workflow.
@@ -0,0 +1,4 @@
interface:
display_name: "Create App"
short_description: "Scaffold and develop Twenty apps."
default_prompt: "Use $create-app to scaffold and run a new Twenty app."
@@ -0,0 +1,116 @@
---
name: develop-app
description: Use when the user wants to add or modify Twenty app entities, including objects, layouts, logic functions, and front components inside an existing Twenty app.
---
# When To Use
Pick this skill when the user wants to change what an existing Twenty app *does* — its data model, UI, logic, or workflows. Representative triggers:
- "add a new object to my Twenty app"
- "create a logic function that runs on record create"
- "add a custom field to the company object"
- "build a front component for the deal page"
- "modify an existing entity / object / field"
- "add a workflow with a manual trigger"
- "add roles and permissions to my app"
- "create a standalone page in my app"
Do not use this skill to scaffold a brand-new app (use `create-app`), to sync/deploy/troubleshoot (use `manage-app`), to prepare marketplace assets (use `publish-app`), or to query workspace records (use `use-twenty-mcp`).
# Boundaries
For background on how Twenty apps work — the SDK packages, remotes, sync lifecycle, and rendering model — read `../../references/concepts/how-apps-work.md`.
Do not scaffold a new app here. Use `create-app` first when the app does not exist.
# Workflow
First, confirm the setup is sane before changing entities. The current directory should be the root of an existing Twenty app:
```bash
test -f package.json
test -f src/application-config.ts
```
If either file is missing, do not edit blindly. Inspect nearby folders to find the app root, or use `create-app` when the app does not exist.
If setup, dependencies, remotes, authentication, sync, build, deploy, logs, or CI/CD are failing, switch to `manage-app` before continuing with entity work.
For app shape and entity file structure, read `../../references/develop-app/app-structure.md`.
## Plan Before Editing
For any change involving more than one entity, state the plan back in 36 lines before editing: objects extended, fields added, logic functions and post-install hooks declared, whether the app needs UI. Confirm with the user when there is a real choice.
Skip for single-entity edits, renames, and copy fixes.
## Code Organization
Keep logic functions, post-install hooks, and front components narrow. Extract everything that is not the trigger, inputs, writes, rendering shell, or external call:
- `src/utils/<name>.util.ts` — pure helpers (parsers, mappers, formatters).
- `src/front-components/utils/<name>.util.ts` — front-component runtime helpers that are reusable and testable.
- `src/types/<name>.ts` — external API and internal DTO types (one PascalCase type per file).
- `src/<service>-client/<name>.ts` — wrappers around external SDKs or shared HTTP clients. One folder per service, matching Twenty's `*-client` convention.
Kebab-case filenames. One export per file is mandatory for every helper, type, and client file in `src/` — never multiple function exports in one file. The rule counts exports: a local (non-exported) type may stay alongside the util, but once a type is exported or reused it splits into a `src/types/<name>.ts` file separate from the `src/utils/<name>.util.ts` file. See `app-structure.md` for the canonical rule and the full suffix convention.
Refactor when:
- A `*.logic-function.ts` or `*.post-install.ts` file exceeds the soft cap (see `../../references/develop-app/logic.md`).
- A front component contains duplicated command execution, record loading, logic-function lookup, payload building, result parsing, or snackbar formatting.
- The same parsing or mapping logic appears across object types.
- Multiple field files differ only by name and identifier — use a factory under `src/fields/`.
Always create a `*.spec.ts` test file in a sibling `__tests__/` folder for every util/function you add or change, one spec file per source file. See `../../references/develop-app/tests.md`.
When a front component triggers a logic function for selected records, always prefer a bulk-capable logic function unless the user explicitly states that the function is only for one record. The default payload shape is `records: Array<{ id: string; ...fields }>`: inside `records`, use `id` for the Twenty record ID because the array name already establishes the record context. Do not add flat single-record compatibility payloads unless the user explicitly asks to preserve an existing single-record API.
## Adding New Entities
Use the app CLI to add new entities. It generates the correct file structure, UUIDs, SDK imports, and boilerplate automatically:
```bash
yarn twenty dev:add
```
This is the default and preferred way to create objects, fields, views, logic functions, front components, and other entities. Do not manually create entity files, explore SDK typings in `node_modules`, or generate UUIDs by hand when the CLI can do it.
Only create entity files manually when modifying existing entities or when the CLI does not support the specific entity type.
## After Entity Changes
Once all edits for the change are complete, run lint and typecheck once at the end (not after each individual edit), then sync the app to the active remote:
```bash
yarn twenty dev:typecheck
yarn lint
yarn twenty dev --once
```
`yarn twenty dev:typecheck` checks generated app types, `yarn lint` checks local lint rules, and `yarn twenty dev --once` syncs entity definitions to the active remote. Run all three a single time once every edit is done, not repeatedly after each step. When the user explicitly asks to run tests, follow `../../references/develop-app/tests.md`.
Use the official Twenty docs or local SDK source when exact entity fields, imports, or configuration shapes matter.
# Develop References
Read the smallest reference that matches the requested entity work:
- Objects, fields, relations, roles, and permissions: `../../references/develop-app/data-model.md`
- Views, navigation, page layouts, page layout tabs, and front component registration: `../../references/develop-app/layout.md`
- Full-page custom UI and standalone page patterns: `../../references/develop-app/standalone-pages.md`
- Front component source, Twenty UI imports, data hooks, runtime imports, and browser verification: `../../references/develop-app/front-components.md`
- Logic functions, skills, agents, post-install hooks, and connection providers: `../../references/develop-app/logic.md`
- Workflows, manual triggers, draft/activate lifecycle, and seeder pitfalls: `../../references/develop-app/workflows.md`
- Tests — what to cover and where to put the files: `../../references/develop-app/tests.md`
- App file structure and entity validation checklist: `../../references/develop-app/app-structure.md`
- Detailed front component UI design: `../../references/design/front-component-ui.md`
For front components, read `front-components.md` before implementation. Use `layout.md` for placement, `standalone-pages.md` for full-page custom UI, and `front-component-ui.md` for visual design and Twenty UI component selection.
# Handoffs
Use `../../references/design/front-component-ui.md` when the entity work turns into front component UI design.
Use `publish-app` when the task turns into README, marketplace copy, screenshots, logos, or listing assets.
@@ -0,0 +1,4 @@
interface:
display_name: "Develop App"
short_description: "Add entities and front components to a Twenty app."
default_prompt: "Use $develop-app to add objects, fields, front components, logic functions, views, roles, or app entities to a Twenty app."
@@ -0,0 +1,201 @@
---
name: manage-app
description: Use when the user wants to manage or troubleshoot tooling, remotes, sync, build, deploy, logs, CI/CD, or operational workflows for an existing Twenty app.
---
# When To Use
Pick this skill when the user wants to operate, troubleshoot, or ship an existing Twenty app — anything between "I have an app" and "it runs in production". Representative triggers:
- "sync my app changes to the server"
- "deploy my Twenty app to production"
- "check the logs for my logic function"
- "switch to a different Twenty remote / instance"
- "add a new remote for staging"
- "set up CI/CD for my Twenty app"
- "troubleshoot a failed deploy / sync / build"
- "uninstall my app from production"
- "run tests for my Twenty app"
- "run my logic function manually for testing"
Do not use this skill to scaffold (use `create-app`), to change app entities or code (use `develop-app`), to prepare marketplace assets (use `publish-app`), or to query workspace records (use `use-twenty-mcp`).
# Boundaries
For background on how Twenty apps work — the SDK packages, remotes, sync lifecycle, and rendering model — read `../../references/concepts/how-apps-work.md`.
Do not scaffold a new app here. Use `$create-app` when the app does not exist.
Do not add or modify app entities here. Use `$develop-app` for objects, fields, logic functions, roles, views, navigation, page layouts, skills, agents, connection providers, and front component registration.
Do not prepare marketplace README, screenshots, logos, or npm listing copy here. Use `$publish-app` for public listing work.
Do not retrieve CRM records here. Use `$use-twenty-mcp` for workspace data retrieval.
# Operating Rules
First confirm the current directory is a Twenty app:
```bash
test -f package.json
test -f src/application-config.ts
```
Inspect current app and scripts before running operational commands:
```bash
sed -n '1,220p' package.json
yarn twenty remote:list
```
Treat deploys, uninstalls, production remote changes, and production syncs as externally visible actions. Ask for explicit confirmation before running them when the target is production or user data could be affected.
For command details, remotes, validation command semantics, sync modes, troubleshooting, build, deploy, logs, exec, and CI/CD, read `../../references/manage-app/cli-and-sync.md`.
If the user asks to run tests, also read `../../references/develop-app/tests.md` before running any test command. Full test suites include integration tests that install and uninstall the app on their target server, so start or verify the isolated test instance and run:
```bash
yarn twenty docker:start --test
TWENTY_API_URL=http://localhost:2021 yarn test
```
Do not run integration tests against the dev instance on `http://localhost:2020` unless the user explicitly asks for that target. If the user asks for unit tests only, use the package's unit-test script and no `TWENTY_API_URL` override is needed.
# Remotes
A remote is a Twenty server that the app can sync or deploy to. Credentials are stored locally in `~/.twenty/config.json`.
Common commands:
```bash
# Add a remote interactively.
yarn twenty remote:add
# Connect to a local Twenty server.
yarn twenty remote:add --local
# Add a remote non-interactively.
yarn twenty remote:add --url https://your-twenty-server.com --api-key $TWENTY_API_KEY --as production
# List configured remotes.
yarn twenty remote:list
# Switch the active remote.
yarn twenty remote:use <name>
```
When the user says "prod", "production", or "workspace de prod", identify the target remote before syncing or deploying. If it is missing, add it with `--as production` or the user-provided name.
# Development Sync
Always use one-shot sync to synchronize app changes with the active remote:
```bash
yarn twenty dev --once
```
Do not use bare `yarn twenty dev` (watch mode). Run `yarn twenty dev --once` each time changes need to be synced.
One-shot sync requires an authenticated remote. If authentication fails, re-add or switch the remote before retrying.
# Troubleshooting
Start by identifying which layer is failing:
- Remote/authentication.
- Dev sync or one-shot sync.
- Build/package generation.
- Deploy/upload/install.
- Runtime function execution.
- CI/CD environment or secrets.
Collect the minimum useful context before changing configuration:
```bash
sed -n '1,220p' package.json
sed -n '1,220p' src/application-config.ts
yarn twenty remote:list
yarn twenty dev --once --verbose
```
For remote or authentication issues:
- Check that the active remote is the intended workspace or server.
- Re-run `yarn twenty remote:add --local` for local app-dev servers.
- Re-run `yarn twenty remote:add --url <url> --as <name>` for remote servers.
- Avoid overwriting a production remote until the target URL is confirmed.
For sync issues:
- Prefer `yarn twenty dev --once --verbose` to get a bounded failure.
- Check generated type or schema errors before editing app entities.
- If the app depends on a changed data model, use `$develop-app` to fix the entity definitions.
For build or deploy issues:
- Run `yarn twenty dev:build` before `yarn twenty app:publish --private` or `yarn twenty app:publish`.
- Check `package.json` version when updating an already deployed app.
- Confirm the target with `yarn twenty app:publish --private --remote <name>` instead of relying on the active remote when production is involved.
For runtime behavior:
- Use `yarn twenty dev:function:logs` to inspect function execution logs.
- Use `yarn twenty dev:function:exec -n <function-name> -p '<json>'` to reproduce a logic function with a controlled payload.
For CI/CD failures:
- Check `.github/workflows/`.
- Confirm `TWENTY_DEPLOY_URL` points to a reachable server from the runner.
- Confirm `TWENTY_DEPLOY_API_KEY` is configured as a secret, not committed in source.
# Build And Deploy
Build before deploy when the user asks for release readiness or when debugging packaging issues:
```bash
yarn twenty dev:build
```
Publish an app to npm, or publish privately to a configured Twenty server registry:
```bash
yarn twenty app:publish
yarn twenty app:publish --private --remote production
yarn twenty app:install --remote production
```
Before deploying an update, check that `package.json` has a strictly higher semver `version` than the currently deployed version. Re-deploying the same version is rejected. After a private publish, install the deployed version with `yarn twenty app:install` when the target workspace should use it.
Use `$publish-app` instead when the user wants npm marketplace publishing, listing copy, screenshots, or public app store metadata.
# Logs, Exec, And Cleanup
Use function logs when debugging runtime behavior on the connected server:
```bash
yarn twenty dev:function:logs
yarn twenty dev:function:logs -n <function-name>
```
Run a logic function manually when testing behavior without its trigger:
```bash
yarn twenty dev:function:exec -n <function-name>
yarn twenty dev:function:exec -n <function-name> -p '{"key":"value"}'
```
Uninstall only after confirmation:
```bash
yarn twenty app:uninstall
yarn twenty app:uninstall --yes
```
# CI/CD
Apps generated with `create-twenty-app` can use GitHub Actions for CI and CD. For deployment automation, check the app's `.github/workflows/` files and configure:
- `TWENTY_DEPLOY_URL`
- `TWENTY_DEPLOY_API_KEY`
Do not put API keys in source files. Use the repository or CI secret store.
@@ -0,0 +1,4 @@
interface:
display_name: "Manage App"
short_description: "Manage and troubleshoot app operations."
default_prompt: "Use $manage-app to manage or troubleshoot Twenty app remotes, sync, build, deploy, logs, or CI/CD."
@@ -0,0 +1,43 @@
---
name: publish-app
description: Use when the user wants to prepare or verify a Twenty app for npm or marketplace publication, including README/About copy, package metadata, defineApplication marketplace metadata, logos, screenshots, and public assets.
---
# When To Use
Pick this skill when the user wants to make an existing Twenty app *presentable* — listing copy, branding, public assets, and publication. Representative triggers:
- "prepare my Twenty app for the marketplace"
- "write the README for my Twenty app"
- "publish my app to npm"
- "add a logo / screenshots / marketplace metadata"
- "set up `defineApplication` marketplace fields"
- "review my app listing before going live"
- "ship a new version of my published app"
Do not use this skill to scaffold (use `create-app`), to change app entities (use `develop-app`), to deploy a private build to a specific Twenty server (use `manage-app`), or to query workspace records (use `use-twenty-mcp`).
# Publishing Checklist
For background on how Twenty apps work — the SDK packages, remotes, sync lifecycle, and rendering model — read `../../references/concepts/how-apps-work.md`.
Use `../../references/publish-app/prepare-for-app-store.md` for detailed README, marketplace copy, logo, screenshot, and public asset guidance.
Keep the skill workflow concise:
- Inspect `package.json`, `README.md`, `src/application-config.ts`, and `public/`.
- For npm marketplace publication, ensure `package.json` has a valid bumped `version` and the `twenty-app` keyword.
- In `defineApplication()`, verify marketplace-facing fields such as `displayName`, `description`, `author`, `category`, `logoUrl`, `screenshots`, `aboutDescription`, `websiteUrl`, `termsUrl`, `emailSupport`, and `issueReportUrl`.
- Keep `logoUrl` and `screenshots` pointed at files in `public/`. Public assets must not contain secrets, private data, customer records, real tokens, or unreleased confidential material.
- Remember that if `aboutDescription` is omitted, the marketplace uses the package `README.md` from npm for the About content.
- Validate with `yarn twenty dev:build`; publish with `yarn twenty app:publish` or `yarn twenty app:publish --tag <tag>`.
# Boundaries
Use `$manage-app` for remotes, deploys to a specific server, logs, troubleshooting, and CI/CD operations.
Use `$develop-app` when the app entities themselves need to change before the listing can be accurate.
# Docs
If the needed publishing detail is unclear, search the official Twenty docs through the bundled `twenty-docs` MCP server before guessing.
@@ -0,0 +1,4 @@
interface:
display_name: "Publish App"
short_description: "Prepare Twenty app listing assets."
default_prompt: "Use $publish-app to prepare README, marketplace metadata, logos, screenshots, and app listing assets."
@@ -0,0 +1,48 @@
---
name: use-twenty-mcp
description: Use when the user wants Codex to connect to an existing Twenty workspace through MCP, retrieve or inspect workspace records and metadata, or present Twenty CRM data as readable Markdown with formatted dates, values, record links, and compact tables instead of raw API output.
---
# When To Use
Pick this skill when the user wants to read from or inspect a running Twenty workspace — not change app code. Representative triggers:
- "list all my companies / people / opportunities in Twenty"
- "show me the records I created this week"
- "what's in my Twenty workspace"
- "connect Codex to my Twenty workspace"
- "set up Twenty MCP for `myworkspace.twenty.com`"
- "query the CRM for X"
- "summarize my pipeline from Twenty"
- "show me the fields on the company object"
Do not use this skill to scaffold an app (use `create-app`), to add or modify app entities (use `develop-app`), to operate an app (use `manage-app`), or to prepare marketplace assets (use `publish-app`).
# What It Is
Twenty MCP connects Codex to an existing Twenty workspace so the agent can inspect workspace data and metadata: records, objects, fields, schema, configuration, and related CRM context.
This is different from a Twenty app. Use `$create-app` when the user wants to scaffold a new app codebase, and `$develop-app` when the user wants to add app-defined features such as objects, fields, logic, layouts, or components.
Use `$use-twenty-mcp` when the user wants to connect Codex to an already running workspace, retrieve or inspect workspace data, inspect metadata, or troubleshoot MCP access.
Do not use MCP as the default way to customize a workspace. For example, prefer creating a new object through a Twenty app rather than directly through MCP. Use MCP for workspace customization only when the user explicitly asks to do it through MCP.
# Setup
Use `../../references/use-twenty-mcp/setup.md` for workspace URL normalization, MCP server configuration, OAuth guardrails, and setup troubleshooting.
# Retrieval
Before retrieving, listing, searching, summarizing, or presenting workspace records, read `../../references/use-twenty-mcp/result-formatting.md`.
Apply that reference when selecting the right Twenty MCP workspace, querying records, building record links, formatting dates and values, and presenting readable Markdown instead of raw API output.
# Output Contract
Before the final answer for retrieved workspace records:
- If the tool output includes `recordReferences`, the first record-name column or record heading MUST link each display name back to Twenty.
- Build links as `{workspaceOrigin}/object/{objectNameSingular}/{recordId}` using the selected workspace origin and the returned `recordReferences`.
- If the workspace origin is not known, resolve it from the selected MCP server URL when possible. If it still cannot be resolved, state that direct record links need the workspace URL instead of inventing a host.
- Never show unlinked record names in a record table when `recordReferences` and the workspace origin are available.
@@ -0,0 +1,4 @@
interface:
display_name: "Use Twenty MCP"
short_description: "Query Twenty MCP and format readable records."
default_prompt: "Use $use-twenty-mcp to connect Codex to Twenty MCP or retrieve readable workspace data."
@@ -0,0 +1,18 @@
{
"_comment": "Copy this file to .agents/plugins/marketplace.json at the repo root or at ~/.agents/plugins/marketplace.json to install the Twenty Codex plugin locally. Adjust source.path if your checkout lives elsewhere.",
"plugins": [
{
"name": "twenty",
"version": "0.1.0",
"category": "Coding",
"source": {
"type": "local",
"path": "./packages/twenty-codex-plugin"
},
"policy": {
"installation": "manual",
"authentication": "user-local"
}
}
]
}
@@ -40,6 +40,7 @@ export default defineField({
- When defining fields **inline inside `defineObject()`**, you do **not** need `objectUniversalIdentifier` — it's inherited from the parent object.
- `defineField()` is the only way to add fields to objects you didn't create with `defineObject()`.
- File location is up to you. The convention is `src/fields/<name>.field.ts`, but the SDK detects fields anywhere in `src/`.
- To add a tab to a standard page layout (e.g. the Task or Company detail page), use [`definePageLayoutTab`](/developers/extend/apps/layout/page-layouts#definepagelayouttab) with `STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS` from `twenty-sdk/define`.
## Adding a relation to an existing object
@@ -84,11 +84,11 @@ export default defineCommandMenuItem({
The `conditionalAvailabilityExpression` field lets you control when a command is visible based on the current page context. Import typed variables and operators from `twenty-sdk` to build expressions:
```ts src/command-menu-items/bulk-update.command-menu-item.ts
import { defineCommandMenuItem } from 'twenty-sdk/define';
import {
defineCommandMenuItem,
objectPermissions,
everyEquals,
} from 'twenty-sdk/front-component';
} from 'twenty-sdk/define';
export default defineCommandMenuItem({
universalIdentifier: '...',
@@ -103,6 +103,10 @@ export default defineCommandMenuItem({
});
```
<Note>
`RECORD_SELECTION` already implies a non-empty selection — use `numberOfSelectedRecords` only for specific counts (e.g. `>= 2`).
</Note>
### Context variables
These represent the current state of the page:
@@ -196,6 +196,94 @@ export default defineFrontComponent({
});
```
## Calling a logic function
Front components run browser-side in a sandboxed Web Worker, 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 exposed under the `/s/` endpoint at `${TWENTY_API_URL}/s<path>`. Your front component calls that route with `fetch`, authenticating with the `TWENTY_APP_ACCESS_TOKEN` that Twenty injects into the worker.
A small reusable helper keeps the call sites clean:
```ts src/shared/call-app-route.ts
export async function callAppRoute(
path: string,
body: Record<string, unknown>,
): Promise<unknown> {
const apiUrl = process.env.TWENTY_API_URL ?? '';
const token = process.env.TWENTY_APP_ACCESS_TOKEN;
const res = await fetch(`${apiUrl}/s${path}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(body),
});
if (!res.ok) {
throw new Error(`Logic function failed (${res.status})`);
}
return res.json();
}
```
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 { defineFrontComponent } from 'twenty-sdk/define';
import { Command } from 'twenty-sdk/command';
import { callAppRoute } from 'src/shared/call-app-route';
const SyncPrs = () => {
const execute = async () => {
await callAppRoute('/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 `callAppRoute` must match the logic function's `httpRouteTriggerSettings.path` (the `/s` prefix is added by the helper):
```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_API_URL` and `TWENTY_APP_ACCESS_TOKEN` are 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>
## Accessing runtime context
Inside your component, use SDK hooks to access the current user, record, and component instance:
@@ -335,8 +423,8 @@ export default defineFrontComponent({
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, numberOfSelectedRecords } from 'twenty-sdk/front-component';
import { defineFrontComponent, numberOfSelectedRecords } from 'twenty-sdk/define';
import { useSelectedRecordIds } from 'twenty-sdk/front-component';
import { enqueueSnackbar, closeSidePanel } from 'twenty-sdk/front-component';
import { CoreApiClient } from 'twenty-sdk/clients';
@@ -65,16 +65,15 @@ Use this when you only want to **add** a tab to an existing layout — for examp
import {
definePageLayoutTab,
PageLayoutTabLayoutMode,
STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import { HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from '../front-components/hello-world';
const COMPANY_RECORD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER =
'20202020-ab01-4001-8001-c0aba11c0100';
export default definePageLayoutTab({
universalIdentifier: 'b1b2b3b4-b5b6-4000-8000-000000000001',
pageLayoutUniversalIdentifier:
COMPANY_RECORD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.companyRecordPage
.universalIdentifier,
title: 'Hello World',
position: 1000,
icon: 'IconWorld',
@@ -97,6 +96,34 @@ export default definePageLayoutTab({
### Key points
- `pageLayoutUniversalIdentifier` is **required** and must point to a page layout that already exists at install time — either a standard Twenty layout or one defined by your own app. Cross-app references to layouts owned by another installed app are not supported today. When the parent layout is missing, installation fails with a clear validation error.
- For standard Twenty layouts, import identifiers from `twenty-sdk/define`:
```ts
import { STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
// STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.companyRecordPage.universalIdentifier
// STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.personRecordPage.universalIdentifier
// STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.taskRecordPage.universalIdentifier
// STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.opportunityRecordPage.universalIdentifier
// STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.noteRecordPage.universalIdentifier
// …
```
Each layout entry also exposes its `tabs` and their `widgets`, so you can reference any level:
```ts
STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.taskRecordPage.tabs.home.universalIdentifier
STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.taskRecordPage.tabs.home.widgets.fields.universalIdentifier
```
A short alias `STANDARD_PAGE_LAYOUT` is also available:
```ts
import { STANDARD_PAGE_LAYOUT } from 'twenty-sdk/define';
STANDARD_PAGE_LAYOUT.companyRecordPage.universalIdentifier;
```
- `widgets` are scoped to this tab only — they reference [front components](/developers/extend/apps/layout/front-components), views, etc. exactly like widgets defined inline in `definePageLayout`.
- `position` controls ordering against existing tabs on the targeted layout. Pick a value that places your tab where you want it relative to built-in tabs.
- Use this instead of `definePageLayout` when you only want to add to an existing layout. Use `definePageLayout` when you own the entire layout.
@@ -53,6 +53,10 @@ export default defineLogicFunction({
Available trigger types:
- **httpRoute**: Exposes your function on an HTTP path and method **under the `/s/` endpoint**:
> e.g. `path: '/post-card/create'` is callable at `https://your-twenty-server.com/s/post-card/create`
<Note>
To invoke a route-triggered logic function from a (headless) front component, see [Calling a logic function](/developers/extend/apps/layout/front-components#calling-a-logic-function).
</Note>
- **cron**: Runs your function on a schedule using a CRON expression.
- **databaseEvent**: Runs on workspace object lifecycle events. When the event operation is `updated`, specific fields to listen to can be specified in the `updatedFields` array. If left undefined or empty, any update will trigger the function.
> e.g. `person.updated`, `*.created`, `company.*`

Some files were not shown because too many files have changed in this diff Show More