We run Twenty next to Attio, not instead of it. These are the things the docs
do not tell you, written after actually shipping apps against a self-hosted instance.
Why it sits next to Attio
5050Growth is an Attio partner. Most of the work we take is still Attio: funds, PE,
relationship-heavy SaaS. That does not change.
Twenty is the answer to a different buyer. They want a modern CRM. They also want a
price they can write down, data that never leaves their region, and a codebase they
can fork if the vendor disappears. Seat-price anxiety kills otherwise good Attio deals.
Self-hosted Twenty is the fallback we can actually stand behind, because we run it.
Cloud Twenty at twenty.com is fine if you just want to try the product. The interesting
part, for us, is the self-hosted path plus the app SDK that shipped in 2026. The catalog
is still small. That is the window.
Three env vars that unblock the platform
Docker Compose brings up server, worker, Postgres 16, Redis. That part is documented.
These three are not, or they are easy to miss, and they are why a fresh self-host feels
broken. Tick what you have set. Copy the .env when you want the file.
LOGIC_FUNCTION_TYPE=LOCAL.
Server-side logic functions are disabled by default on self-hosted. Every call 403s
with no useful message until this is set. LOCAL runs each call in a real Node child
process that inherits container env, so process.env.X works
for secrets. You do not need the Admin Panel variable machinery for a self-built app.
TWENTY_API_URL and
TWENTY_API_KEY.
Logic functions call back into Twenty through CoreApiClient.
If the URL is wrong, or the container cannot resolve its own public hostname, the
function dies after it has already started. Put the public hostname in
extra_hosts on server and worker, pointing at the
docker-bridge gateway, not at 127.0.0.1. Loopback has no TLS.
Google sync flags on both server and worker:
MESSAGING_PROVIDER_GMAIL_ENABLED and
CALENDAR_PROVIDER_GOOGLE_ENABLED.
The BullMQ cron jobs register themselves on boot. The docs still tell you to register
cron:messaging:* by hand. Skip that on current versions.
App SDK traps that cost a day
Scaffold with
(Node 24).
Dev loop: typecheck,
,
then
.
Prefer the generated
schema.graphql over guessing field shapes.
Front components run in a worker inside a sandboxed iframe. More of the DOM is
allowed than the docs suggest: real CSS including hover and keyframes, the full SVG
set, paste events with clipboard text. A few things will still empty a side panel
with zero error surface.
Always unmount in a finally.
Headless command buttons disable while the component is mounted. If it crashes or
never finishes, the toolbar button sticks disabled until a refresh.
Never RestApiClient inside the sandbox.
Relative URLs do not resolve. Transport is plain fetch against
TWENTY_FUNCTIONS_URL with the injected app token.
frontComponentId is the database id,
not the universal identifier. Pass the wrong one and
FindOneFrontComponent returns null forever.
Resolve at runtime against metadata.
recordId without objectNameSingular is dropped.
The panel then sees an empty selection.
Multi-select never reaches side panels.
The host collapses to a single record context. Workaround we use: the headless
trigger packs ids comma-separated into recordId, the panel splits them back out.
Fragile. Flag it in code.
CSS injected from a front component lands on the host page and is global.
Namespace every selector. SVG presentation attributes do not accept
var(); put the variable on a style or a class.
There is no focus() bridge, so do not plan arrow-key cell navigation.
Rate limit on the core API is 100 requests per minute per token. Plural mutations
take a filter, not an id list. Deletes do not cascade: remove participants before
messages before threads, or ghosts stay attached to the person.
What we shipped against it
In one day on the self-hosted instance: a HeyReach connector (select people, pick a
campaign in a side panel, batch add) and a finance mini-spreadsheet tab on every
Company record. Spreadsheet paste works because the sandbox forwards
clipboardText. The host still applies the native
paste afterwards, so guard the following onChange or it overwrites what you just spilled.
Apps can attach tabs to standard record-page layouts. That is how the finance widget
lives on Company without forking Twenty. Fields on standard objects use
defineField plus the standard universal identifier.
FieldType.RAW_JSON round-trips objects through GraphQL.
When Twenty is the right CRM
Pick Twenty when at least two of these are true: the buyer will not sign an uncapped
SaaS CRM; data must stay on their infrastructure; someone on the team can run Docker
and read a GraphQL schema; they want custom record-page UI, not just custom fields.
Pick Attio when the buyer wants a polished relationship CRM this quarter, native
email and calendar that just work, an Expert who has done their exact shape of
business fifteen times, and they are fine paying for that. That is most of our
clients. Pretending otherwise would be a different company.
Notion is a third lane, not a CRM. Use it when the operating system is a wiki with
a few databases, not a pipeline with ownership, email sync, and reporting. We will
write that note next.
copied
Lab command palette
enter run · esc close · / or ⌘K
[{"id":"jump-twenty","group":"Jump","label":"Twenty","href":"/lab/#twenty"},{"id":"jump-skills","group":"Jump","label":"Skills","href":"/lab/#skills"},{"id":"jump-demo","group":"Jump","label":"CSV matcher","href":"/lab/#demo"},{"id":"jump-proof","group":"Jump","label":"Audit proof","href":"/lab/#proof"},{"id":"jump-notes","group":"Jump","label":"Notes log","href":"/lab/#notes"},{"id":"open-note","group":"Jump","label":"Open Twenty field note","href":"/lab/twenty/"},{"id":"copy-env","group":"Twenty","label":"Copy Twenty .env","copy":"LOGIC_FUNCTION_TYPE=LOCAL\nTWENTY_API_URL=https://your-host.example\nTWENTY_API_KEY=\nMESSAGING_PROVIDER_GMAIL_ENABLED=true\nCALENDAR_PROVIDER_GOOGLE_ENABLED=true"},{"id":"copy-compose","group":"Twenty","label":"Copy docker compose up","copy":"docker compose up -d"},{"id":"copy-audit","group":"Proof","label":"Copy attio-audit command","copy":"ATTIO_API_KEY= python skills/attio-audit/audit.py"},{"id":"copy-attio-toolkit","group":"attio-toolkit","label":"Copy install · attio-toolkit","hint":"git clone https://github.com/NachoLafuente/attio-toolkit.git","copy":"git clone https://github.com/NachoLafuente/attio-toolkit.git"},{"id":"gh-attio-toolkit","group":"attio-toolkit","label":"GitHub · attio-toolkit","href":"https://github.com/NachoLafuente/attio-toolkit"},{"id":"cursor-attio-toolkit","group":"attio-toolkit","label":"Open in Cursor · attio-toolkit","href":"cursor://anysphere.cursor-deeplink/prompt?text=Clone%20https%3A%2F%2Fgithub.com%2FNachoLafuente%2Fattio-toolkit.git%20and%20load%20the%20skills.%0AStart%20with%20%2Fattio-audit.%20Read-only%20unless%20I%20pass%20a%20write%20flag.","copy":"git clone https://github.com/NachoLafuente/attio-toolkit.git"},{"id":"claude-attio-toolkit","group":"attio-toolkit","label":"Open in Claude Code · attio-toolkit","href":"https://claude.ai/code?prompt=Clone+https%3A%2F%2Fgithub.com%2FNachoLafuente%2Fattio-toolkit.git+and+load+the+skills.%0AStart+with+%2Fattio-audit.+Read-only+unless+I+pass+a+write+flag.&repositories=NachoLafuente%2Fattio-toolkit","copy":"git clone https://github.com/NachoLafuente/attio-toolkit.git"},{"id":"gpt-attio-toolkit","group":"attio-toolkit","label":"Open in ChatGPT · attio-toolkit","href":"https://chatgpt.com/?q=Clone%20https%3A%2F%2Fgithub.com%2FNachoLafuente%2Fattio-toolkit.git%20and%20load%20the%20skills.%0AStart%20with%20%2Fattio-audit.%20Read-only%20unless%20I%20pass%20a%20write%20flag.","copy":"git clone https://github.com/NachoLafuente/attio-toolkit.git"},{"id":"skill-attio-audit","group":"attio-toolkit","label":"/attio-audit","hint":"Score an Attio workspace 0-100. Read-only, ~10 API calls.","copy":"/attio-audit"},{"id":"skill-attio-backup","group":"attio-toolkit","label":"/attio-backup","hint":"Snapshot the workspace to JSON + CSV, then diff two runs.","copy":"/attio-backup"},{"id":"skill-attio-schema","group":"attio-toolkit","label":"/attio-schema","hint":"ER diagram of the data model. Schema only, never records.","copy":"/attio-schema"},{"id":"skill-attio-attribute-coverage","group":"attio-toolkit","label":"/attio-attribute-coverage","hint":"Which fields are filled vs dead. Flags bloat under 10%.","copy":"/attio-attribute-coverage"},{"id":"skill-attio-stale-records","group":"attio-toolkit","label":"/attio-stale-records","hint":"Untouched records and obvious archive candidates.","copy":"/attio-stale-records"},{"id":"skill-attio-cost-explorer","group":"attio-toolkit","label":"/attio-cost-explorer","hint":"Estimate the Attio bill across plan tiers.","copy":"/attio-cost-explorer"},{"id":"copy-5050-gtm","group":"5050-gtm","label":"Copy install · 5050-gtm","hint":"git clone https://github.com/NachoLafuente/5050-gtm.git","copy":"git clone https://github.com/NachoLafuente/5050-gtm.git"},{"id":"gh-5050-gtm","group":"5050-gtm","label":"GitHub · 5050-gtm","href":"https://github.com/NachoLafuente/5050-gtm"},{"id":"cursor-5050-gtm","group":"5050-gtm","label":"Open in Cursor · 5050-gtm","href":"cursor://anysphere.cursor-deeplink/prompt?text=Clone%20https%3A%2F%2Fgithub.com%2FNachoLafuente%2F5050-gtm.git%20and%20load%20the%20skills.%0AStart%20with%20%2Fcohort-analysis.%20Read-only%20unless%20I%20pass%20a%20write%20flag.","copy":"git clone https://github.com/NachoLafuente/5050-gtm.git"},{"id":"claude-5050-gtm","group":"5050-gtm","label":"Open in Claude Code · 5050-gtm","href":"https://claude.ai/code?prompt=Clone+https%3A%2F%2Fgithub.com%2FNachoLafuente%2F5050-gtm.git+and+load+the+skills.%0AStart+with+%2Fcohort-analysis.+Read-only+unless+I+pass+a+write+flag.&repositories=NachoLafuente%2F5050-gtm","copy":"git clone https://github.com/NachoLafuente/5050-gtm.git"},{"id":"gpt-5050-gtm","group":"5050-gtm","label":"Open in ChatGPT · 5050-gtm","href":"https://chatgpt.com/?q=Clone%20https%3A%2F%2Fgithub.com%2FNachoLafuente%2F5050-gtm.git%20and%20load%20the%20skills.%0AStart%20with%20%2Fcohort-analysis.%20Read-only%20unless%20I%20pass%20a%20write%20flag.","copy":"git clone https://github.com/NachoLafuente/5050-gtm.git"},{"id":"skill-cohort-analysis","group":"5050-gtm","label":"/cohort-analysis","hint":"Cohort table from CRM + revenue. Excel plus per-section CSVs.","copy":"/cohort-analysis"},{"id":"skill-ltv-cac","group":"5050-gtm","label":"/ltv-cac","hint":"LTV, CAC payback, LTV/CAC with four frameworks side by side.","copy":"/ltv-cac"},{"id":"skill-cfo-skill","group":"5050-gtm","label":"/cfo-skill","hint":"Runway, burn, MRR from CSVs or live Attio/Qonto/Stripe.","copy":"/cfo-skill"},{"id":"skill-call-coach","group":"5050-gtm","label":"/call-coach","hint":"Discovery-call coaching from a Granola, Fireflies, or plain transcript.","copy":"/call-coach"},{"id":"skill-disco-prep","group":"5050-gtm","label":"/disco-prep","hint":"Two-minute brief before a disco call: who they are, what to ask.","copy":"/disco-prep"},{"id":"skill-gsc-lighthouse","group":"5050-gtm","label":"/gsc-lighthouse","hint":"Search Console health plus Lighthouse on the top pages by clicks.","copy":"/gsc-lighthouse"},{"id":"skill-linkedin-self-improvement-loop","group":"5050-gtm","label":"/linkedin-self-improvement-loop","hint":"One LinkedIn experiment per cycle. A human always posts.","copy":"/linkedin-self-improvement-loop"},{"id":"skill-attio-deepline-enrich","group":"5050-gtm","label":"/attio-deepline-enrich","hint":"Enrich Attio records with a cost gate before any credit is spent.","copy":"/attio-deepline-enrich"},{"id":"copy-5050-vc-skills","group":"5050-vc-skills","label":"Copy install · 5050-vc-skills","hint":"/plugin marketplace add NachoLafuente/5050-vc-skills","copy":"/plugin marketplace add NachoLafuente/5050-vc-skills"},{"id":"gh-5050-vc-skills","group":"5050-vc-skills","label":"GitHub · 5050-vc-skills","href":"https://github.com/NachoLafuente/5050-vc-skills"},{"id":"cursor-5050-vc-skills","group":"5050-vc-skills","label":"Open in Cursor · 5050-vc-skills","href":"cursor://anysphere.cursor-deeplink/prompt?text=%2Fplugin%20marketplace%20add%20NachoLafuente%2F5050-vc-skills%0A%2Fplugin%20install%205050-vc-skills%0A%0AThen%20run%20%2Fattio-vc-ic-pipeline-prep.%20Read-only.%20Invent%20nothing.","copy":"/plugin marketplace add NachoLafuente/5050-vc-skills"},{"id":"claude-5050-vc-skills","group":"5050-vc-skills","label":"Open in Claude Code · 5050-vc-skills","href":"https://claude.ai/code?prompt=%2Fplugin+marketplace+add+NachoLafuente%2F5050-vc-skills%0A%2Fplugin+install+5050-vc-skills%0A%0AThen+run+%2Fattio-vc-ic-pipeline-prep.+Read-only.+Invent+nothing.&repositories=NachoLafuente%2F5050-vc-skills","copy":"/plugin marketplace add NachoLafuente/5050-vc-skills"},{"id":"gpt-5050-vc-skills","group":"5050-vc-skills","label":"Open in ChatGPT · 5050-vc-skills","href":"https://chatgpt.com/?q=%2Fplugin%20marketplace%20add%20NachoLafuente%2F5050-vc-skills%0A%2Fplugin%20install%205050-vc-skills%0A%0AThen%20run%20%2Fattio-vc-ic-pipeline-prep.%20Read-only.%20Invent%20nothing.","copy":"/plugin marketplace add NachoLafuente/5050-vc-skills"},{"id":"skill-attio-vc-ic-pipeline-prep","group":"5050-vc-skills","label":"/attio-vc-ic-pipeline-prep","hint":"Dealflow hygiene grade plus what moved since the last IC.","copy":"/attio-vc-ic-pipeline-prep"},{"id":"skill-attio-vc-ic-memo-prep","group":"5050-vc-skills","label":"/attio-vc-ic-memo-prep","hint":"Per-deal IC memo skeleton from fields and notes on file.","copy":"/attio-vc-ic-memo-prep"},{"id":"skill-attio-vc-portfolio-health","group":"5050-vc-skills","label":"/attio-vc-portfolio-health","hint":"Board and KPI cadence on companies already in the book.","copy":"/attio-vc-portfolio-health"},{"id":"skill-attio-vc-coinvestor-map","group":"5050-vc-skills","label":"/attio-vc-coinvestor-map","hint":"Syndicate coverage percent and a ranked co-investor table.","copy":"/attio-vc-coinvestor-map"}]