Need telephony working with your CRM, and one clean place for all the information it collects? Talk to Matthew on X.
A cold calling dialer that keeps every record in Twenty CRM and puts the phone in the browser.
There is no separate database. Prospects, leads, campaigns, scripts, phone
numbers and calls are all agency* custom objects in Twenty, and every server
in this repository is a thin translator between an HTTP client and the Twenty
REST API. The call itself is SIP over WebRTC straight from the browser to
Telnyx; the recording is Telnyx server-side.
- The workflow
- What it does
- The three surfaces
- Architecture
- Data flow
- The call
- Data model
- Running it
- Configuration
- Repository layout
- API reference
- Documentation map
- Checks
- License
Two ways to work, depending on whether you need the browser softphone.
Needs a softphone, so this is the standalone path (frontend/ or railcode/).
The browser holds the SIP call itself.
- Open a lead or prospect. The script for its campaign loads next to the dialer, with its objection handling.
- Claim a number.
POST /phones/:id/claimmoves itIDLEtoDIALING. If another member holds it you get a 409 naming them, and the dial aborts before any SIP traffic. - Dial. The call row opens as
IN_PROGRESSbefore the INVITE, so a call that fails to connect is still on record. - Talk. Hold, mute and redial from the softphone. Telnyx is already recording server-side from the moment it connects.
- Save a disposition. One action patches the call status, runs the recording reconcile, releases the number, and moves the prospect or lead status.
- Review. The recording and transcript are on the call row, attached by webhook a few seconds after you hang up. Play them from call history.
The native app has no softphone, so you call from your own handset and write the result back. No second login, no extra host.
- Open the Queue tab - prospects with their current status.
- Call from your own handset.
- Log the call inline - select rows, set the outcome, and it creates the
agencyCallsrow for you. - Claim a number only if you are also sending an SMS or a website link.
Both paths read and write the same six objects, so status carries forward in either direction. A prospect that showed interest becomes a lead, and the script and the offer follow it.
flowchart TB
subgraph pathA ["A - calling from the dialer"]
direction TB
A1["Open a lead or prospect<br/>the script for its campaign loads next to the dialer"]
A2["Claim a number<br/>POST /phones/:id/claim<br/>IDLE to DIALING, 409 if someone holds it"]
A3["Dial<br/>SIP INVITE from the browser<br/>call row opens as IN_PROGRESS"]
A4["Talk<br/>hold, mute, redial<br/>Telnyx is already recording"]
A5["Save a disposition<br/>patch status, reconcile the recording,<br/>release the number"]
A6["Review<br/>play the recording, read the transcript,<br/>update the prospect or lead status"]
A1 --> A2 --> A3 --> A4 --> A5 --> A6
end
subgraph pathB ["B - logging inside Twenty"]
direction TB
B1["Open the Queue tab<br/>prospects with their current status"]
B2["Call from your own handset<br/>the native app has no softphone"]
B3["Log the call inline<br/>select rows, set the outcome,<br/>create the agencyCalls row"]
B4["Claim a number only if you<br/>are sending SMS or a website link"]
B1 --> B2 --> B3 --> B4
end
A6 -.->|"status carries forward"| B1
B3 -.->|"becomes a lead when it sticks"| A1
shared[("Shared state in Twenty<br/>agencyProspects agencyLeads agencyCalls<br/>agencyPhones agencyCampaigns agencyScripts")]
A5 --> shared
A6 --> shared
B3 --> shared
B4 --> shared
note["The claim lock is the only thing preventing<br/>two agents dialing out of the same number.<br/>It lives on the agencyPhones row, so it holds<br/>across servers, restarts and all three surfaces."]
A2 -.-> note
B4 -.-> note
classDef store fill:#eef2ff,stroke:#4f46e5,color:#1e1b4b
classDef note fill:#fffbeb,stroke:#d97706,color:#451a03
class shared store
class note note
Source:
docs/diagrams/agent-workflow.mmd.
The claim lock is the only thing stopping two agents dialing out of the same
number. It lives on the agencyPhones row rather than in server memory, so it
holds across servers, restarts, and all three surfaces. Read
architecture.md before changing it: it is also the
thinnest security in the codebase.
- Browser softphone. SIP over WebRTC, dial from a lead or a prospect record, hold, mute, redial, and take inbound calls.
- Number locking. One member holds a number for the duration of a call, so two agents cannot dial from the same line. Enforced in Twenty, so it holds across servers and restarts.
- Call recording and transcription. Started server-side through Telnyx Call Control, attached to the call row by webhook, playable from the call history.
- Prospect and lead lifecycle. Status, notes, industry, campaign assignment, CSV import, and conversion from prospect to lead.
- Campaigns and scripts. Organise calling effort, attach a script and its objection handling to a campaign, and surface it while a call is up.
- Runs inside Twenty. A native app gives an agent the queue, the numbers and the call log without a second login.
Same product, same six objects, three independent code paths. None calls another.
| Directory | Server | Use it for | |
|---|---|---|---|
| Standalone | frontend/ + backend/ |
Express on :4000 |
the softphone, Telnyx, recording |
| Workspace | railcode/ |
Hono on Railcode | a private, org-only deployment |
| Native | twenty-native-app/ |
none, runs inside Twenty | dialing without a second host |
The native app is the newest and the direction of travel. The standalone path is the only one with a real softphone: the native app does not implement SIP/WebRTC, recording, or audio playback.
railcode/ is a hand-maintained port of frontend/. The softphone is
byte-identical between them; when you fix a bug in one, expect to apply it in
the other.
flowchart TB
subgraph pathA ["Path A - the repo hosts a server"]
direction TB
spa["frontend/<br/>Vite SPA, browser softphone"]
express["backend/<br/>Express on :4000<br/>JWT_SECRET, bcrypt"]
hono["railcode/<br/>Hono worker on Railcode<br/>org connector, no API key in the worker"]
spa -->|"VITE_API_URL, or same-origin"| express
hono -.->|"same UI, different base URL"| spa
end
subgraph pathB ["Path B - the dialer runs inside Twenty"]
direction TB
widget["DialerApp front component<br/>Remote-DOM sandbox, 5 tabs"]
apilayer["front-components/dialer/api.ts<br/>RestApiClient"]
logic["33 logic functions<br/>/dialer/*<br/>isAuthRequired, 15s timeout"]
client["lib/dialer-client.ts<br/>lazy RestApiClient + MetadataApiClient"]
widget --> apilayer --> logic --> client
end
keyA["Auth: the repo's own JWT, or the Railcode platform session.<br/>Twenty is called with a static workspace API key."]
keyB["Auth: the Twenty workspace session.<br/>TWENTY_APP_ACCESS_TOKEN, refreshed on 401."]
express --> keyA
hono --> keyA
client --> keyB
hop1{{"Hop 1: /s/dialer/*<br/>functions host"}}
hop2{{"Hop 2: /rest/agency*<br/>record host"}}
apilayer -->|"RestApiClient, path starts with /s/<br/>routes to TWENTY_FUNCTIONS_URL"| hop1
logic --> hop1
logic --> hop2
client --> hop2
express --> store
hono --> store
hop2 --> store[("Twenty records<br/>agencyProspects agencyLeads agencyCampaigns<br/>agencyScripts agencyPhones agencyCalls")]
onlyB["Only Path A has: SIP/WebRTC softphone,<br/>Telnyx record_start and reconcile,<br/>audio proxy, call logs, profiles,<br/>CSV import, schema bootstrap, offers"]
onlyA["Only Path B has: in-workspace UI,<br/>no second login, no extra host"]
express -.-> onlyB
logic -.-> onlyA
classDef hop fill:#fdf4ff,stroke:#a21caf,color:#4a044e
classDef note fill:#fffbeb,stroke:#d97706,color:#451a03
class hop1,hop2 hop
class onlyA,onlyB,keyA,keyB note
Source:
docs/diagrams/integration-paths.mmd. It records what only each path can do, which is the question this repository gets asked most.
flowchart TB
actor["Agent<br/>a sales rep on a desk"]
subgraph surfaces ["Deployment surfaces (pick one or run several)"]
direction TB
native["Twenty native app<br/>twenty-native-app/<br/>in-workspace page + 33 logic functions"]
spa["Standalone SPA<br/>frontend/<br/>Vite + React, browser softphone"]
api["Express API<br/>backend/<br/>port 4000, JWT auth"]
worker["Railcode worker<br/>railcode/<br/>Hono, platform session"]
hook["Webhook receiver<br/>frontend/api/telnyx-webhook.ts<br/>Vercel serverless"]
end
subgraph twenty ["Twenty CRM (system of record)"]
objects["agency* custom objects<br/>prospects, leads, campaigns,<br/>scripts, phones, calls"]
meta["Metadata API<br/>SELECT options, schema bootstrap"]
rest["REST API<br/>/rest/agency*"]
pg[("Postgres<br/>core.user<br/>password check only")]
end
telnyx["Telnyx<br/>SIP trunk + Call Control<br/>recording + transcription"]
actor --> native
actor --> spa
actor --> worker
spa -->|"HTTPS /api/*"| api
worker -->|"HTTPS /api/*"| worker
spa <-->|"WSS SIP over WebRTC"| telnyx
api -->|"Bearer API key"| rest
worker -->|"org connector 'twenty'"| rest
native -->|"logic functions"| rest
api --> meta
worker --> meta
native --> meta
api -->|"bcrypt verify"| pg
hook -->|"Bearer API key"| rest
telnyx -.->|"webhook events"| hook
classDef ext fill:#f4f4f5,stroke:#71717a,color:#18181b
classDef store fill:#eef2ff,stroke:#4f46e5,color:#1e1b4b
class telnyx,actor ext
class objects,pg store
Source:
docs/diagrams/system-context.mmd.
Read docs/architecture.md for the route tables, the auth model, and the places where the security is thinner than it looks.
flowchart TB
UI["Agent clicks<br/>a record, a number, or the dial button"]
subgraph read ["Read path"]
direction LR
hooks["React Query hooks<br/>staleTime: Infinity for CRM data,<br/>30s for calls, 15s poll for phones"]
client["apiClient<br/>VITE_API_URL or same-origin"]
hooks --> client
end
client -->|"GET /api/leads<br/>GET /api/prospects<br/>GET /api/campaigns<br/>GET /api/scripts<br/>GET /api/twenty/phones<br/>GET /api/calls"| servers
subgraph servers ["Server (exactly one of these)"]
direction TB
express["Express routers<br/>backend/src/routes/*<br/>authMiddleware, JWT"]
hono["Hono worker<br/>railcode/server/index.ts<br/>ctx.user, platform session"]
logic["Logic functions<br/>twenty-native-app/src/logic-functions/*<br/>isAuthRequired, 15s timeout"]
end
servers -->|"listTwentyAll / listTwentyPage<br/>keyset walk, id strictly ascending"| walk["Twenty REST<br/>orderBy=id[AscNullsFirst]<br/>filter=id[gt]:lastId, limit 200/page"]
subgraph write ["Write path"]
direction LR
mutate["useMutation / api.* directly<br/>no optimistic updates:<br/>onSuccess then invalidateQueries"]
end
mutate -->|"POST / PATCH / DELETE /api/*"| servers
servers -->|"createTwenty / updateTwenty / deleteTwenty<br/>field allow-list per route"| rest["Twenty REST<br/>/rest/agency*"]
walk --> rest
rest --> store[("Twenty records")]
note["Keyset pagination note:<br/>this Twenty build ignores startingAfter,<br/>offset and page, and caps limit at 200,<br/>so every list walks id ascending."] -.-> walk
classDef note fill:#fffbeb,stroke:#d97706,color:#451a03
class note note
Source:
docs/diagrams/data-flow.mmd.
The one thing to know: this Twenty build ignores startingAfter, offset and
page, and caps limit at 200. Cursor pagination does not work, so every list
is a keyset walk over id, ascending, 200 per page, bounded.
GET /rest/agencyProspects
?limit=200
&orderBy=id[AscNullsFirst]
&filter=id[gt]:"<last id seen>"
More on this, and on the read and write paths, in docs/data-flow.md.
sequenceDiagram
autonumber
participant A as Agent
participant SP as Softphone.tsx
participant API as API (Express / Hono)
participant TW as Twenty CRM
participant TX as Telnyx
participant AI as AI (OpenAI-compatible)
A->>SP: press dial (phone = GET /api/twenty/phones/primary row)
Note over SP,API: one canonical agency number from agencyPhones -<br/>no hardcoded caller id, no per-call pool picks
SP->>API: POST /api/twenty/phones/:id/claim {memberId}
API->>TW: PATCH agencyPhones callState=DIALING, claimedBy*
API-->>SP: 200, or 409 heldBy when another member holds it
Note over SP,API: a 409 aborts the dial before any SIP traffic
SP->>API: POST /api/calls (IN_PROGRESS, from, to, agencyPhoneId, agencyProspectId or agencyLeadId)
API->>TW: POST /rest/agencyCalls
API-->>SP: call row id
SP->>API: GET /api/netcheck?host&port
Note over SP,API: best effort, a failure only warns and the dial proceeds
SP->>TX: WebSocket connect, then REGISTER
SP->>SP: getUserMedia audio
SP->>TX: INVITE sip:target@domain, P-Asserted-Identity header
TX-->>SP: 200 OK
Note over SP,TX: STEP 1 of 2 - read X-Telnyx-Call-Control-ID<br/>in inviter.invite requestDelegate.onAccept.<br/>The Inviter constructor delegate does not fire this in sip.js 0.21.
SP->>API: PATCH /api/calls/:id {telnyxCallId}
Note over SP,API: STEP 2 of 2 - stamp the id BEFORE /record,<br/>otherwise /record reads a row with no telnyxCallId and 400s
SP->>API: POST /api/calls/:id/record
API->>TX: calls.actions.startRecording {mp3, dual, transcription:true}
TX-->>API: recording_id
API->>TW: PATCH agencyCalls telnyxRecordingId, transcriptionStatus=PENDING
API-->>SP: ok
TX-->>SP: SIP 200, media flowing
SP->>API: POST /api/twenty/phones/:id/state {memberId, state:ACTIVE}
API->>TW: PATCH agencyPhones callState=ACTIVE
TX-->>SP: call.recording.saved
TX-->>SP: call.recording.transcription.saved
Note over TX,SP: delivered to the webhook receiver, not the API.<br/>receiver PATCHes recordingUrl then transcript + transcriptionStatus=READY
TX->>API: POST /api/webhooks/telnyx?token= (or Vercel /api/telnyx-webhook?token=)
Note over TX,API: same contract both surfaces:<br/>token-gated, 200 on unknown events, 500 only on real errors (Telnyx retries)
API->>TW: PATCH agencyCalls telnyxRecordingId/recordingUrl, transcriptionStatus=PENDING
API->>TW: PATCH agencyCalls transcript, transcriptionStatus=READY
API->>AI: chat/completions {transcript} (single OPENAI-compatible key)
AI-->>API: {summary, sentiment, score 0-100, keyPoints, confidence}
API->>TW: PATCH agencyCalls aiSummary/aiSentiment/aiScore/aiKeyPoints/aiConfidence/aiModel/aiAnalyzedAt (+summary mirror)
Note over API,TW: every call row carries its own rating -<br/>no side tables, visible in Twenty CRM directly
A->>SP: hang up
SP->>TX: BYE
SP->>API: PATCH /api/calls/:id {status, endedAt, durationSeconds, telnyxCallId, debugLog}
API->>TW: PATCH agencyCalls
A->>SP: save disposition
SP->>API: PATCH /api/calls/:id {status}
SP->>API: POST /api/calls/:id/reconcile
Note over SP,API: repair path. Matches a Telnyx recording by<br/>from/to within a 15 minute window when the<br/>call-control-id was never captured.
SP->>API: POST /api/twenty/phones/:id/release {memberId, callId}
API->>TW: PATCH agencyPhones callState=IDLE, claimedBy* cleared
A->>SP: play recording
SP->>API: GET /api/calls/:id/audio
API->>TX: recordings.retrieve(telnyxRecordingId)
TX-->>API: download_urls.mp3 (expires in about 10 minutes)
API-->>SP: 302 to the fresh URL
Note over API,TX: the Telnyx API key never leaves the server
A->>SP: analyze (or auto after transcription webhook)
SP->>API: POST /api/calls/:id/analyze
API->>AI: chat/completions {transcript}
AI-->>API: {summary, sentiment, score, keyPoints, confidence}
API->>TW: PATCH agencyCalls ai* fields (+summary mirror)
API-->>SP: ok + analysis - Rating column shows score/sentiment
Source:
docs/diagrams/call-lifecycle.mmd.
Two steps in that sequence are load-bearing, and both have broken the recording before:
- The Telnyx call-control id has to be read in the
requestDelegatepassed toinviter.invite(). TheInviterconstructor delegate does not fireonAcceptin sip.js 0.21. - That id has to be stamped onto the row with a
PATCHbeforePOST /api/calls/:id/record, because/recordre-reads the row and returns 400 whentelnyxCallIdis empty.
POST /api/calls/:id/reconcile is the repair path when the id was never
captured: it matches a Telnyx recording by from and to within a 15 minute
window.
Telnyx download URLs expire in about ten minutes, so GET /api/calls/:id/audio
re-resolves a fresh URL and 302s to it. The API key never leaves the server.
stateDiagram-v2
direction LR
[*] --> IDLE
IDLE --> DIALING: POST /phones/:id/claim<br/>writes callState=DIALING,<br/>claimedByMemberId, claimedByEmail, claimedAt
note right of IDLE
claim is refused with 409
when callState is not IDLE and
the holder is a different member.
The 409 body carries heldBy.
end note
DIALING --> DIALING: re-claim by the same member<br/>idempotent, refreshes claimedAt
DIALING --> ACTIVE: POST /phones/:id/state {ACTIVE}<br/>fired on SIP Established
DIALING --> IDLE: POST /phones/:id/release<br/>on hang up, on failure, on unmount
DIALING --> IDLE: POST /phones/:id/release {force:true}<br/>admin override, skips the holder check
ACTIVE --> DIALING: POST /phones/:id/state {DIALING}
ACTIVE --> IDLE: POST /phones/:id/release
IDLE --> [*]
note left of DIALING
release writes currentCallId so the
number still points at the call it
was last used for.
end note
Source:
docs/diagrams/phone-claim.mmd.
The claim state lives on the agencyPhones row in Twenty rather than in server
memory, so it survives a restart and every surface sees the same answer. The
full state machine, including the 409 and force paths, is in the diagram.
erDiagram
agencyCampaigns ||--o{ agencyProspects : "campaignIdId"
agencyCampaigns ||--o{ agencyLeads : "campaignIdId"
agencyCampaigns ||--o{ agencyScripts : "campaignIdId"
agencyCampaigns ||--o{ agencyOffers : "urlKey, industryId"
agencyPhones ||--o{ agencyCalls : "agencyPhoneId"
agencyProspects ||--o{ agencyCalls : "agencyProspectId"
agencyLeads ||--o{ agencyCalls : "agencyLeadId"
agencyProspects ||--o| agencyLeads : "agencyProspectId"
agencyLeads ||--o{ agencyCallLogs : "leadId, legacy"
agencyCampaigns {
text name
select status "ACTIVE INACTIVE DRAFT"
select campaignType "OUTBOUND INBOUND BLENDED REFERRAL COLD_CALL WEBSITE TWENTY_IMPORT OTHER"
text note "JSON settings blob"
text utmSource
text industryId
text urlKey
text funnelBaseUrl
text templateBaseUrl
}
agencyProspects {
text name
text phone
text email
text website
text fullAddress
text city
text region
text country
text niche
number rating
number reviewCount
select coldCallStatus "NEW CONTACTED INTERESTED NOT_INTERESTED CALLBACK CONVERTED DO_NOT_CONTACT"
text outboundState
text outboundLabel
text utmSource
text campaignIdId "relation to agencyCampaigns"
}
agencyLeads {
text name
text contactName
text email
text phone
text company
text source
text status
text note
select coldCallStatus
text createdById
text campaignIdId "relation to agencyCampaigns"
}
agencyScripts {
text name
text scriptData "JSON: script body plus objection responses"
text campaignIdId "relation to agencyCampaigns"
}
agencyPhones {
text phoneNumber "E.164"
text name
text countryCode "ISO alpha-2"
select numberType "LONG_CODE TOLL_FREE SHORT_CODE"
select state "ACTIVE PAUSED DEGRADED RETIRED"
text messagingProfileId
select callState "IDLE DIALING ACTIVE - the claim lock"
text claimedByMemberId
text claimedByEmail
datetime claimedAt
datetime lastHeartbeatAt
text currentCallId
text lastSyncedAt
}
agencyCalls {
text name "generated"
select direction "INBOUND OUTBOUND MISSED"
select status "IN_PROGRESS COMPLETED FAILED NO_ANSWER BUSY"
text fromNumber
text toNumber
datetime startedAt
datetime endedAt
number durationSeconds
text telnyxCallId "X-Telnyx-Call-Control-ID, captured from the 200 OK"
text telnyxRecordingId
text recordingUrl "expires, play via /api/calls/:id/audio"
text transcript
select transcriptionStatus "NONE PENDING READY FAILED"
text summary "mirrors aiSummary once analyzed"
text aiSummary "1-2 sentence AI summary, on the row itself"
text aiSentiment "POSITIVE NEUTRAL NEGATIVE MIXED, the prospect"
number aiScore "0-100, how the call went"
text aiKeyPoints "JSON string array, max 5"
text aiScores "JSON 1-5: conversion, politeness, questioning, engagement, sentiment"
number aiConfidence "0-1 model confidence"
text aiModel "OPENAI_ANALYSIS_MODEL id"
datetime aiAnalyzedAt
text debugLog "SIP event trail, 8KB cap"
text meetingUrl
text meetingProvider
datetime meetingAt
select meetingStatus
text meetingBookingId
text agencyPhoneId "relation to agencyPhones"
text agencyProspectId "relation to agencyProspects"
text agencyLeadId "relation to agencyLeads"
}
agencyOffers {
text name "INDUSTRY:urlKey"
select status
select videoMode "PROSPECT"
}
agencyCallLogs {
text name "direction: outcome"
text leadId
text userId
text campaignId
text recordingUrl
number duration_seconds
}
Source:
docs/diagrams/data-model.mmd.
POST /api/setup/twenty creates four of these idempotently:
agencyProspects, agencyLeads, agencyCampaigns, agencyScripts, plus the
coldCallStatus and utmSource selects and the campaignId relations. It does
not create agencyPhones, agencyCalls, or agencyOffers; those have to
exist in the workspace already.
One API rule worth memorising: Twenty writes relation fields as
{fieldName}Id, so a relation declared as campaignId is sent as
campaignIdId.
sequenceDiagram
autonumber
participant SRC as Lead source<br/>dialer UI, Twenty UI,<br/>CSV import, API
participant API as API (Express)
participant TW as Twenty CRM
participant BARK as Bark server<br/>api.day.app or self-hosted
participant APNS as Apple APNs
participant IPH as Member iPhone<br/>Bark app installed
SRC->>API: POST /api/leads {contact, company, phone}
API->>TW: POST /rest/agencyLeads
TW-->>API: lead row id
API->>API: markLeadNotified(id)<br/>10-minute cross-path dedupe
API->>API: broadcastNewLead (fire-and-forget)<br/>a push failure never fails the lead
SRC->>TW: lead created outside the dialer<br/>Twenty UI, CSV, API, workflow
TW-->>API: POST /api/twenty/webhooks<br/>{event: agencyLead.created, data}
Note over TW,API: token or HMAC gate, non-lead events<br/>ack 2xx and ignore, known ids dedupe-skip
API->>TW: GET /rest/agencyLeads/:id<br/>full lead metadata (webhook payload can be partial)
API->>TW: GET /rest/workspaceMembers
TW-->>API: members with barkKey<br/>BARK_KEY metadata field, RICH_TEXT markdown
Note over API,TW: members without a BARK_KEY are skipped<br/>their key was never stored on the object
loop every member that has a BARK_KEY
API->>BARK: POST /push {device_key, title, body, url}<br/>url is {FRONTEND_URL}/leads/:leadId
BARK->>APNS: push payload
APNS-->>IPH: notification appears
end
IPH->>IPH: member taps the notification
IPH->>API: open /leads/:leadId<br/>LeadDetailPage, the lead itself, not a list
Records the dialer writes are attributed to the member who is signed in, not to
the API key. Two channels are written side by side, and both depend on schema
that POST /api/setup/twenty creates.
flowchart TB
subgraph twenty ["Twenty (identity provider + system of record)"]
wm["workspaceMember<br/>the signed-in human<br/>id, userId, userEmail, name"]
actor["createdBy Actor<br/>system actor, source API<br/>SETTABLE via REST"]
own["createdByMemberId<br/>own TEXT field on the object<br/>queryable UUID, SETTABLE"]
beat["agencyPhone.lastHeartbeatAt<br/>DATE_TIME, SETTABLE"]
end
subgraph auth ["Identity (backend)"]
sess["POST /api/oauth/session<br/>introspect token -> resolve member"]
jwt["dialer JWT<br/>workspaceMemberId, memberName"]
mw["authMiddleware<br/>req.workspaceMemberId, req.memberName"]
guard["requireMember()<br/>401 when the session has no member"]
end
subgraph writes ["Attribution on write"]
calls["POST /api/calls<br/>createdBy Actor + createdByMemberId"]
leads["POST /api/leads<br/>createdById, assigned_to"]
pro["POST /api/prospects<br/>createdBy Actor + createdByMemberId"]
end
subgraph claim ["Number claim lifecycle (same member)"]
hb["POST /phones/:id/heartbeat<br/>every 3s, holder only"]
rel["POST /phones/:id/release<br/>holder only, or force"]
end
twenty -->|"introspect sub / username"| sess
sess --> wm
wm -->|"resolved row"| sess
sess --> jwt --> mw --> guard
guard -->|"member id is server-derived,<br/>never taken from the request body"| calls
guard --> leads
guard --> pro
guard --> hb
guard --> rel
calls --> actor
calls --> own
pro --> own
pro --> actor
hb --> beat
rel --> beat
note1["updatedBy is NOT settable.<br/>Twenty recomputes it from the<br/>authenticated caller, so it stays<br/>the API actor. Read createdBy."]
actor -.- note1
```mermaid
Source:
docs/diagrams/member-attribution.mmd.
The member id is derived server-side from the JWT, never read from a request body, so a caller cannot claim to be someone else. Two consequences worth knowing:
createdByis settable and reads back the real member.updatedByis not settable. Twenty recomputes it from the authenticated caller, so it keeps reporting the API actor. Attribution readscreatedBy.
Because both channels write plain fields, the object must actually have them.
GET /api/setup/twenty/status lists every field the routes read or write and
reports exists: false for anything the workspace is still missing.
agencyProspects.coldCallStatus
| Value | Meaning |
|---|---|
NEW |
no contact made |
CONTACTED |
initial contact made |
INTERESTED |
showed interest |
NOT_INTERESTED |
declined |
CALLBACK |
needs a callback |
CONVERTED |
became a lead |
DO_NOT_CONTACT |
do not call again |
agencyCalls.status
| Value | Meaning |
|---|---|
IN_PROGRESS |
dialled, not yet wrapped up |
COMPLETED |
answered, or a wrap-up status the UI does not distinguish |
NO_ANSWER |
rang out |
BUSY |
busy |
FAILED |
transport or setup failure |
agencyPhones.callState
| Value | Meaning |
|---|---|
IDLE |
free |
DIALING |
claimed, call not yet up |
ACTIVE |
answered |
Full field tables are in docs/okf/datamodel/dialer.md.
git clone https://github.com/matthewdonsemail-lab/dialer.git
cd dialer
bun install
bun run install:all # root, backend, frontend
cp .env.example .env.local # then fill it in; see below
cp backend/.env.example backend/.env.local
cp frontend/.env.example frontend/.env.local
bun run dev # backend on :4000, frontend on :3000Open http://localhost:5173 and sign in with an account that already exists in
Twenty. Signup is disabled: the dialer verifies credentials against Twenty's
core."user" table rather than keeping its own.
The native app is a separate build with its own toolchain:
cd twenty-native-app
yarn install
yarn twenty app:publish --private
yarn twenty app:installtwenty-native-app/AGENTS.md is the Twenty team's own guide for this project
structure, and it is worth reading before changing anything under
twenty-native-app/src/.
More in docs/quick-start.md and SETUP.md.
Full reference in SETUP.md. The shape of it:
# Twenty CRM. Required by every server.
TWENTY_BASE_URL=https://twenty.example.com
TWENTY_API_KEY=
# Only for backend/. Used for exactly one query: verify a password
# against core."user". Locally this goes through an SSH tunnel
# because the tailnet ACL blocks direct 5432:
# ssh -L 5433:localhost:5432 -N <host>
TWENTY_DATABASE_URL=postgres://twenty:xxx@127.0.0.1:5433/twenty
# backend/ only. Signs its own JWTs.
JWT_SECRET=
PORT=4000
# Telnyx. Server-side only, never VITE_.
TELNYX_API_KEY=
TELNYX_WEBHOOK_TOKEN= # shared gate for the webhook receiver (?token=)# frontend/. Baked into the bundle at build time.
VITE_API_URL=http://localhost:4000
VITE_SIP_URI=sip:username@sip.telnyx.com
VITE_SIP_PASSWORD=
VITE_SIP_WS_URL=wss://sip.telnyx.com:7443
VITE_SIP_CALLER_ID=+15551234567
VITE_SIP_PROVIDER=telnyxVITE_SIP_* values are compiled in, so changing one needs a rebuild. That is
also why a redeploy can serve a bundle with stale SIP config if the CDN is
cached; the app detects a stale chunk and reloads with a cache buster.
SIP is not Telnyx-specific. See docs/sip-providers.md.
dialer/
├── backend/ Express API, port 4000
│ └── src/
│ ├── lib/ twenty-client, twenty-object-service, telnyx
│ ├── middleware/ auth (JWT)
│ ├── db/ twenty-pg (the one password query); schema.ts is dead
│ └── routes/ one file per resource
├── frontend/ Vite SPA, the browser softphone
│ ├── api/telnyx-webhook.ts Telnyx webhook receiver (Vercel function)
│ └── src/
│ ├── components/softphone/ the dial path
│ ├── sip/ config, diagnostics, failure classification
│ ├── hooks/ React Query hooks
│ └── pages/ routes
├── railcode/ same UI, Hono worker, Railcode deployment
│ ├── server/ worker, twenty connector access
│ └── frontend/ the ported UI
├── twenty-native-app/ the dialer as a Twenty app
│ └── src/
│ ├── logic-functions/ 33 /dialer/* routes
│ ├── front-components/ DialerApp, api.ts, FieldCell
│ └── lib/ dialer-client
├── docker/ Dockerfiles, compose, nginx configs
├── docs/ everything below
│ ├── diagrams/ six .mmd files, the source of the diagrams above
│ ├── telnyx/ Telnyx notes; upstream/ is a gitignored mirror
│ ├── plans/ scoped but unbuilt design work
│ └── marketing/ launch copy, not documentation
└── scripts/ deploy, setup, doc mirrors, pre-push checks
Deliberately not tracked: host-specific infrastructure, unrelated apps,
vendored third-party skills, deploy staging output, and credentials. See
.gitignore and
scripts/twenty-schema/README.md.
scripts/check-scope.mjs fails the push if any of them comes back.
Mounted by backend/src/index.ts, mirrored in railcode/server/index.ts, and
served inside Twenty at /dialer/*.
| Method | Path | Notes |
|---|---|---|
| POST | /api/auth/login |
Twenty core."user" + bcrypt. Signup is disabled. |
| GET | /api/auth/me |
Current user from the JWT. |
| GET POST PATCH DELETE | /api/leads |
CRUD. |
| GET POST PATCH DELETE | /api/prospects |
CRUD. |
| GET | /api/prospects/:id/website-status |
Resolves the industry funnel and offer URLs. |
| POST | /api/prospects/:id/website-sent |
Advances outboundLabel to SMS_IN_PROGRESS. |
| POST | /api/prospects/:id/ensure-offer |
Idempotently creates the INDUSTRY:<key> offer. |
| GET POST PATCH DELETE | /api/campaigns |
CRUD. |
| GET POST PATCH DELETE | /api/scripts |
CRUD. scriptData is a JSON string. |
| GET | /api/twenty/phones |
Inventory with live claim state. |
| POST | /api/twenty/phones/:id/claim |
IDLE to DIALING. 409 with heldBy when taken. |
| POST | /api/twenty/phones/:id/state |
DIALING or ACTIVE. Holder only. |
| POST | /api/twenty/phones/:id/release |
Back to IDLE. Holder only unless force. |
| GET | /api/twenty/meta/:object |
SELECT options, from the Twenty metadata API. |
| POST | /api/setup/twenty |
Idempotent schema bootstrap. |
| GET | /api/calls |
Newest first. |
| GET | /api/calls/:id/audio |
302 to a freshly resolved Telnyx mp3. |
| POST | /api/calls/:id/record |
record_start with transcription. Needs telnyxCallId. |
| POST | /api/calls/:id/reconcile |
Repair path: match a recording by from and to. |
| POST | /api/calls |
Creates the row. Best effort, also stamps currentCallId. |
| PATCH | /api/calls/:id |
Disposition, recording, transcript, meeting, debugLog. |
| GET | /api/health |
Config flags. |
| GET | /api/netcheck |
Allowlisted TCP probe. Best effort. |
/api/call-logs and /api/profiles are legacy, read the old call log object,
and are not behind authMiddleware. Everything else is.
Full index with descriptions: docs/README.md.
Understand it
- Architecture - the three surfaces, the servers, the auth model
- Data flow - reads, writes, keyset pagination, the schema
- Diagrams - all six, with the source that implements each
Run it
- Quick start
- Setup reference
- Design system - tokens, table primitives, the Tailwind gotcha
- SIP providers
Ship it
- Deployment
- Contributing - hooks, checks, commit convention
- Changelog
When it breaks
Reference, not documentation
- Plans - scoped but unbuilt design work
- Marketing - launch copy
- Data model snapshot
- Twenty schema helpers - local only, gitignored
docs/telnyx/upstream/,docs/twenty/upstream/- mirrored vendor docs, gitignored. Fetch withbun run docs:telnyxandbun run docs:twenty.
bunx lefthook install # once, after cloningThree pre-push gates, each a plain node scripts/check-*.mjs with no
dependencies:
| Check | Fails when |
|---|---|
check-docs.mjs |
a documented file is missing, a diagram is unlisted, a diagram is invalid Mermaid, the README does not embed it, or a relative link is broken |
check-scope.mjs |
anything outside the application's scope is tracked in git |
check-no-emojis.mjs |
an emoji appears in a tracked file |
check-secrets.mjs |
a credential is committed: a JWT, a private key, a cloud or provider key, or a connection string with a real password |
Run them by hand any time:
bun run check:docs
bun run check:scope
bun run check:secrets
node scripts/check-no-emojis.mjs
node scripts/test-diagram-lint.mjsBypass an emergency with SKIP_DOCS_CHECK=1, SKIP_SCOPE_CHECK=1,
SKIP_EMOJI_CHECK=1, or SKIP_SECRET_CHECK=1.
MIT. See LICENSE.
If this is useful, a star helps someone else find it.
