The Twenty fork — 5 workspaces, one login per member, and a backend that sleeps when the laptop does

CRM

Databayt runs a self-hosted Twenty fork (databayt/twenty, branch databayt) with one workspace per product. Every team member has one login that works on all workspaces — sign in once on any link, then move between them with the workspace switcher (top-left).

Workspaces

ProductURLPipeline
Hogwartshogwarts.databayt.orgSchool leads — 3,206 rows, the scrape and funnel lanes both work this one
Mkanmkan.databayt.orgProperty hosts and listings
Sijilleesijillee.databayt.orgPre-launch prospects
Moallimeemoallimee.databayt.orgPre-launch prospects
Databaytsales.databayt.orgTech jobs (Next.js/React/TS), software house partnerships, per-project client services — plus the Kigali custom object (below)

The old *.crm.databayt.org hosts still resolve but 307-redirect to the same path on <product>.databayt.org. The default landing host is app.databayt.org.

mkan.databayt.org is the CRM, not the mkan app. The app is mk.databayt.org, migrating to mkan.sd. A script that mints an outreach or claim link must never default to the CRM host. These names also collide with hogwarts' tenant wildcard, which provisions schools at <domain>.databayt.org — guarded by hogwarts/src/lib/reserved-subdomains.ts.

Custom objects

The workspaces run Twenty's standard objects with one exception. The Databayt workspace carries a custom object, Kigali (kigaliOpportunity), added 2026-08-24 as the pipeline for the Kigali job search and the worldwide remote developer lane.

It exists because Opportunity could not hold the work. An opportunity has name, amount, closeDate and a five-value sales stage (NEW|SCREENING|MEETING|PROPOSAL|CUSTOMER) — nowhere to put a campaign, a recommendation tier, an engine score, a job URL or an application deadline, and a sales stage means nothing for a job hunt. Sales deals keep Opportunities; jobs live next door.

FieldTypeCarries
companyRELATION → companyThe employer, reverse-labelled "Kigali"
campaignSELECTProtection · Electrical · Marine ETO · Web Developer · Remote Worldwide
tierSELECTThe Job Engine's 5D recommendation
engineScoreNUMBER5D overall, 0-100
applicationStatusSELECTTo apply → Applied → Response → Interview → Offer
remoteType · employmentType · locationSELECT · SELECT · TEXTShape of the role; location carries the timezone constraint on remote rows
jobUrlLINKSThe posting or direct-approach target
deadlineDATE_TIMEWhere the posting states one
source · fingerprintTEXTProvenance, and the engine's dedup hash
assessmentRICH_TEXTDescription, skills, and the engine's reasoning

Written by jobs/push-kigali-to-twenty.mjs and by the 1-click button on every /jobs card. Both dedupe on fingerprint — sha256(company:title:remoteType) truncated to 16 chars, the same hash the engine uses, so a record is the same record in both systems. The Opportunities object still carries duplicate [Job] rows from an earlier push that had no such check.

Markets and tracks (2026-10-03). Three more fields group the board by where and for whom: country (SELECT — Rwanda, Kenya, Nigeria, Uganda, Tanzania, Sudan, Saudi Arabia, UAE, Qatar, Gulf-other, Remote, Other), city (TEXT) and track (SELECT — Founder for Abdout's own income, Databayt for the company's). campaign gained Client Project for posted client work: RFPs and the databayt.org /wizard requests, which pnpm jobs:requests pulls in as HOLD cards. The fields are filled at push time from the posting's location (src/lib/jobs/markets.ts); pnpm jobs:markets --apply backfills empty ones.

Funding & Programs

A second custom object, Funding & Programs (fundingProgram), holds incubators, accelerators, grants, cloud credits, competitions, fellowships and investors. Abdout asked for it to be separate from the jobs board: a program carries a cohort, equity terms and a ticket size, and a job card has none of these.

FieldTypeCarries
companyRELATION → companyThe operator, reverse-labelled "Funding"
programTypeSELECTCredits · Grant · Incubator · Accelerator · Competition · Fellowship · Angel · VC · Loan
statusSELECTTo review → Eligible / Not eligible → Preparing → Submitted → Interview → Accepted / Rejected → Claimed
country · citySELECT · TEXTThe same country list as the jobs board, plus Pan-Africa and Global
beneficiarySELECTFounder · Databayt · Hogwarts
equity · amountUsd · ladderRungSELECT · NUMBER · NUMBERTerms; rung 1–5 on the non-dilutive-first ladder
deadline · cohortStartDATE_TIMEEmpty deadline = rolling
eligibilityRICH_TEXTFit for a Sudanese national resident in Rwanda
programUrl · applyUrl · source · fingerprintLINKS · TEXTDedup on fingerprint = slug(organisation:name)

Seeded from jobs/funding.seed.json by pnpm jobs:funding --apply. Re-runs create nothing, and a card Abdout has moved keeps its status. The jobs digest lists programs that close within 14 days.

Writing custom objects

The metadata API is open on this build — GET/POST/PATCH/DELETE on /rest/metadata/objects and /rest/metadata/fields. Three shapes are easy to get wrong and all three cost a 400:

  • RICH_TEXT is a composite, { blocknote, markdown }, not a string. An import fills markdown and leaves blocknote null; Twenty renders the rest.
  • LINKS is { primaryLinkUrl, primaryLinkLabel, secondaryLinks }, not a URL string. company.domainName is a LINKS field, which surprises people.
  • A RELATION field needs relationCreationPayload: { targetObjectMetadataId, targetFieldLabel, targetFieldIcon, type } where type is MANY_TO_ONE or ONE_TO_MANY.

Set isLabelSyncedWithName: false whenever the label differs from the name — which is always, for a plural like "Kigali" over kigaliOpportunities.

scripts/crm-kigali-object.mjs and scripts/crm-funding-object.mjs create their objects and fields idempotently through scripts/lib/twenty-object.mjs (ensureObject; a SELECT only grows, live options keep their ids); scripts/lib/twenty-rest.mjs holds the shared client (Keychain auth, 800 ms spacing, 429 backoff).

Twenty shows every new field as a column, in creation order and unsorted. On the Databayt (sales) workspace, scripts/crm-organize.mjs declares how each board reads: the columns and their order, the default sort, and the working views. Jobs gets Pipeline (a kanban by status), Needs me (HOLD) and In play, plus two lane boards: Electrical Engineer (Electrical + Protection) and Marine ETO. Funding gets Pipeline and By deadline. Website Leads gets Pipeline and Tier A. It only changes labels, icons and views, never an API name or a SELECT value. It is idempotent, so a second run is a no-op. After adding a field, run it again (--dry-run first) and place the field in its board's columns.

Topology

PieceWhere
FrontendTwenty's React SPA on Vercel
Backendtwenty-server + twenty-worker + postgres:16 + redis, in Docker on Abdout's Mac, host port 3100
Public APITailscale Funnel — https://twenty-api-2.tail42a5c4.ts.net
Versionpinned v2.31.1 (upstream is ~v2.30 on its own numbering; the fork's checkout is v2.17)

The CRM is down whenever the laptop is. Every scheduled CRM job is a Mac job — which is why the funnel's cadence clock runs on launchd and Vercel cron rather than a Twenty CRON workflow. The worker must be up too: webhooks, crons and the workflow engine all run through BullMQ.

API access

REST + metadata API only. Never psql into a workspace schema: the REST surface is what the in-progress packages/twenty-api rewrite preserves, and raw SQL skips search vectors, timeline and activity. There is deliberately no Twenty MCP configured — turning it on means changing a pinned, restore-verified backend, and the scripts cost zero tokens as they are.

export TWENTY_API_URL=http://localhost:3100   # NEVER 3000 — that is hogwarts' dev server
export TWENTY_API_KEY=$(security find-generic-password -s databayt-twenty -a hogwarts -w)

Tokens live in the macOS Keychain under service databayt-twenty, account = workspace name. They are signed with APP_SECRET, so moving hosts with a different secret invalidates all four. Limits: 100 requests/minute, 60 records per batch call.

Defaulting to port 3000 is not hypothetical — it answers 307 on /graphql, which is why the Hermes CRM crons silently returned nothing for weeks while reporting last_status: ok.

Webhooks

Both products receive real-time events from their workspace. Registered 2026-08-18 and verified end to end against production, not just configured:

WorkspaceTargetOperations
hogwartsed.databayt.org/api/webhooks/twentycompany.updated, opportunity.updated
mkanmk.databayt.org/api/webhooks/twentyhome.updated, host.updated, opportunity.updated

Twenty signs each delivery with X-Twenty-Webhook-Signature — an HMAC-SHA256 over `${timestamp}:${body}` — not an Authorization bearer, and the secret is stripped from the payload before signing, so verification must run against the raw request text. Secrets are per-product and live only in Vercel; a receiver with none configured refuses every delivery rather than accepting it.

Three properties worth designing around: 5-second timeout, no retries, and private IPs are SSRF-blocked. So a receiver must ack fast and do its slow work elsewhere, a dropped event is gone for good, and local development needs a tunnel rather than localhost.

A 200 is not proof of anything. mkan's receiver answered 200 for every delivery for months while processing none of them — it read fields Twenty does not send, so every branch fell through. Both ends reported success the entire time. hogwarts' receiver therefore stores a durable row and keeps received and applied as separate, separately observable facts.

Login

PropertyValue
Username<name>@databayt.org — abdout, aseel, sedon, moutaz, ibrahim, samia, moed
ScopeThe same login on every workspace
SwitcherTop-left, after signing in anywhere

Security — accounts were provisioned with a shared bootstrap password. Change yours on first login. A shared credential across the team and four workspaces is a real risk now that client data (school pipelines, contacts) lives here. Rotation is each member's first task.

Backups

twenty/scripts/backup-twenty.sh, launchd com.databayt.twenty-backup, daily 03:30. It dumps Postgres and tars the storage volume — STORAGE_TYPE=local, so attachments are not in the database and a SQL-only dump would silently lose them. Lands in ~/backups/twenty (14 days) and Google Drive (30 days).

Ownership

Aseel owns pipeline hygiene. The captain mirrors headline numbers into .claude/memory/pipeline.json during weekly planning; that mirror is manual and drifts. Machine-readable workspace registry: .claude/memory/crm.json.