Cloudflare OS ships Gatekeepers for GitHub, Google, Slack, Notion, Confluence, Supabase and a handful of others. It does not ship one for Twenty CRM, and — because upstream is not accepting outside code contribution — it is not going to. So we wrote one, and this is what the work actually involved.
Why the auth model is the first decision
Most connectors in the upstream repository use OAuth, because most of the systems they cover are hosted services with a central identity provider and a developer console you can register an app against.
Twenty is usually self-hosted. There is no central directory to register with, every deployment lives at a different address, and API keys are issued from the instance’s own settings screen. That makes it the same shape as the Home Assistant connector rather than the Google one: the user pastes an address and a key, and the connector validates the pair before storing anything.
The validation call matters more than it looks. We check GET /rest/workspaceMembers?limit=1 and inspect the content type, not just the status code — because a Twenty instance behind a sign-in proxy answers an unauthenticated API call with an HTML login page and a 200, not a 401. Without that check the connect flow succeeds, stores credentials that cannot work, and fails confusingly later. If your CRM sits behind a proxy, give the /rest path an exception first.
The three API shapes that cost the most time
Every system has two or three quirks that consume most of the integration effort. For Twenty they are:
Nested response envelopes. A list returns {data: {people: [...]}}, a fetch returns {data: {person: {...}}}, and a write returns {data: {createPerson: {...}}}. The inner key is not reliably derivable from the path, so the client takes candidate keys and falls back to the first object-valued property rather than guessing per call.
Composite fields. A name is {firstName, lastName}. An email is {primaryEmail, additionalEmails}. A link is {primaryLinkUrl, primaryLinkLabel}. Money is {amountMicros, currencyCode} — millionths, not units. The asymmetry is the trap: filters address the leaf (emails.primaryEmail[eq]:someone@example.com) while writes address the parent ({emails: {primaryEmail: "…"}}).
Notes and tasks link through join tables. Attaching a note is two calls — POST /rest/notes with bodyV2: {markdown}, then POST /rest/noteTargets with {noteId, personId}. Tasks work the same way through taskTargets. Reading them back means querying the join table with depth=1, or the note does not ride along.
One more: pagination caps at 60 records per page, and asking for more is a 400 rather than a silent clamp.
What the approval model forces you to decide
A Gatekeeper is not a thin API wrapper. Building one makes you classify every method as either an observation, which is logged before data is returned, or an action, which is queued and does not touch the system until a human approves it.
The interesting part is what happens while a write waits. Rather than stalling the agent, this connector simulates pending changes by overlaying them at read time: update a contact’s city and the next read returns the new value; create a person and they appear in the next listing. The agent keeps working and the user approves in a batch later.
That leaves one honest gap, which we documented in the type definitions so a calling agent can work around it: a record created through the API carries a provisional id prefixed with ~ until the write lands. It works with every method here, because an internal map redirects it once the real id exists, but it is not a CRM id and must never be shown to a person.
The auto-approval list is deliberately empty. Every write here lands on customer records.
The observer question most integrations skip
When a Gadget is shared, a colleague may see data the agent already read. The framework makes you decide whether they are allowed to — the code does not type-check until you answer.
Twenty’s answer is the single-unit ACL check. An API key is issued per workspace and carries that workspace’s full read access; there is no narrower per-record permission to test against. So a workspace binding confirms the prospective observer’s own account reaches the same deployment, and a record binding additionally confirms they can read that exact record right now. Both checks run against the observer’s own key, never the owner’s, and they re-run on every open, so a revoked key is caught the next time someone opens the app.
The failure handling is worth copying: a 401, 403 or 404 all mean no access, but any other error is rethrown rather than swallowed, so a transient outage never quietly reads as a denial.
Install it
The connector is a drop-in package. From the root of your Cloudflare OS checkout:
git clone https://github.com/emergedigital2024/cloudflare-os-gatekeeper-twenty.git \
packages/gatekeeper-twenty
pnpm install
pnpm run-local
The directory name matters — the dev server discovers connectors by the gatekeeper- prefix — and discovery happens at startup, so restart rather than expecting a reload. You should see env.GATEKEEPER_TWENTY in the binding list.
Apache-2.0, and not affiliated with either Cloudflare or Twenty.
If you are writing your own
The spine generalises. Before you start, find out how the system nests its responses, which user-visible fields are objects rather than scalars, whether attaching a child record goes through a join table, what the page-size ceiling does when exceeded, and whether there is any per-person permission an observer check could test against. Answer those five and the rest is mechanical.
And write it in your own repository. Upstream asks that pull requests stay under roughly a dozen lines, so a connector of any real size belongs to you — which is also why the connectors that matter for regional and self-hosted systems will keep living outside the main project.