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
| Product | URL | Pipeline |
|---|---|---|
| Hogwarts | hogwarts.databayt.org | School leads — 3,206 rows, the scrape and funnel lanes both work this one |
| Mkan | mkan.databayt.org | Property hosts and listings |
| Sijillee | sijillee.databayt.org | Pre-launch prospects |
| Moallimee | moallimee.databayt.org | Pre-launch prospects |
| Databayt | sales.databayt.org | Tech 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.orgis the CRM, not the mkan app. The app ismk.databayt.org, migrating tomkan.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 byhogwarts/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.
| Field | Type | Carries |
|---|---|---|
company | RELATION → company | The employer, reverse-labelled "Kigali" |
campaign | SELECT | Protection · Electrical · Marine ETO · Web Developer · Remote Worldwide |
tier | SELECT | The Job Engine's 5D recommendation |
engineScore | NUMBER | 5D overall, 0-100 |
applicationStatus | SELECT | To apply → Applied → Response → Interview → Offer |
remoteType · employmentType · location | SELECT · SELECT · TEXT | Shape of the role; location carries the timezone constraint on remote rows |
jobUrl | LINKS | The posting or direct-approach target |
deadline | DATE_TIME | Where the posting states one |
source · fingerprint | TEXT | Provenance, and the engine's dedup hash |
assessment | RICH_TEXT | Description, 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.
| Field | Type | Carries |
|---|---|---|
company | RELATION → company | The operator, reverse-labelled "Funding" |
programType | SELECT | Credits · Grant · Incubator · Accelerator · Competition · Fellowship · Angel · VC · Loan |
status | SELECT | To review → Eligible / Not eligible → Preparing → Submitted → Interview → Accepted / Rejected → Claimed |
country · city | SELECT · TEXT | The same country list as the jobs board, plus Pan-Africa and Global |
beneficiary | SELECT | Founder · Databayt · Hogwarts |
equity · amountUsd · ladderRung | SELECT · NUMBER · NUMBER | Terms; rung 1–5 on the non-dilutive-first ladder |
deadline · cohortStart | DATE_TIME | Empty deadline = rolling |
eligibility | RICH_TEXT | Fit for a Sudanese national resident in Rwanda |
programUrl · applyUrl · source · fingerprint | LINKS · TEXT | Dedup 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_TEXTis a composite,{ blocknote, markdown }, not a string. An import fillsmarkdownand leavesblocknotenull; Twenty renders the rest.LINKSis{ primaryLinkUrl, primaryLinkLabel, secondaryLinks }, not a URL string.company.domainNameis aLINKSfield, which surprises people.- A
RELATIONfield needsrelationCreationPayload: { targetObjectMetadataId, targetFieldLabel, targetFieldIcon, type }wheretypeisMANY_TO_ONEorONE_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
| Piece | Where |
|---|---|
| Frontend | Twenty's React SPA on Vercel |
| Backend | twenty-server + twenty-worker + postgres:16 + redis, in Docker on Abdout's Mac, host port 3100 |
| Public API | Tailscale Funnel — https://twenty-api-2.tail42a5c4.ts.net |
| Version | pinned 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
CRONworkflow. 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:
| Workspace | Target | Operations |
|---|---|---|
| hogwarts | ed.databayt.org/api/webhooks/twenty | company.updated, opportunity.updated |
| mkan | mk.databayt.org/api/webhooks/twenty | home.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
receivedandappliedas separate, separately observable facts.
Login
| Property | Value |
|---|---|
| Username | <name>@databayt.org — abdout, aseel, sedon, moutaz, ibrahim, samia, moed |
| Scope | The same login on every workspace |
| Switcher | Top-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.
Related
- Mkan Social Strategy & Listings
- Home — a home written in Slack
#mkanbecomes ahomerecord here, then a listing - Media Studio & Walkthrough Scenarios
- Scrape — finding and enriching leads, up to the first reply
- Funnel — everything after the reply, through to a paying school
- Team — who's who + the one-login-everywhere identity
- Slack —
databayt-shworkspace, same identity