Call
The call module manages call records — inbound and outbound — from creation through post-call details with transcriptions, recordings, and analysis results. Users can place outbound calls directly from the app using the Twilio Voice SDK.
Data Model
Section titled “Data Model”Call (List View)
Section titled “Call (List View)”pub struct Call { pub id: Uuid, pub from_to_number: String, // "+15551234567 -> +15559876543" pub received_on: String, // Friendly name + number pub call_sid: String, // Twilio call SID pub has_recording: bool, pub created_at: String, // Context-aware friendly format pub created_at_full: String, // Full datetime for tooltips pub duration_secs: Option<i32>, pub status: CallStatus, pub direction: CallDirection, pub contact_name: Option<String>, // Linked contact display name pub contact_id: Option<String>, // Contact UUID for routing}received_on is derived at query time by joining the phone_number record:
- If the phone has a
friendly_name:"{friendly_name} ({number})" - If no friendly name is set: the raw E.164 number
- If the phone record is missing:
"Unknown phone"
contact_name and contact_id are batch-loaded from the contact table using a single query per page. The API collects unique contact_id values from the page results, fetches them in one query, and builds a HashMap<Uuid, String> for O(1) lookups. This avoids N+1 queries regardless of page size.
CallStatus
Section titled “CallStatus”Call status is modeled as a typed enum (not a raw string):
pub enum CallStatus { InProgress, // Call is actively connected Completed, // Call ended and recording processed Unknown, // Catch-all for future Twilio status values}Status is stored in the DB as a kebab-case string ("in-progress", "completed") and parsed at query time using call.status.parse::<CallStatus>().unwrap_or(CallStatus::Unknown).
CallDirection
Section titled “CallDirection”pub enum CallDirection { Inbound, // Incoming call received by the platform Outbound, // Outgoing call initiated from the platform}Stored as a kebab-case string ("inbound", "outbound") in the database.
CallDetails (Detail View)
Section titled “CallDetails (Detail View)”Extends Call with transcription, analysis, and cross-entity navigation fields:
pub struct CallDetails { pub id: Uuid, pub from_to_number: String, pub call_sid: String, pub has_recording: bool, pub created_at: String, // datetime-local format: "2026-03-28T15:00" pub transcription: Option<String>, pub contact_name: Option<String>, // Linked contact display name pub contact_id: Option<String>, // Contact UUID for client-side routing pub analysis_groups: Vec<CallAnalysisGroup>,}contact_name and contact_id are resolved server-side using a select_only query for the contact’s first and last name, scoped to the session’s organization. If the call has no linked contact or the contact was deleted, both fields are None.
CallFilter
Section titled “CallFilter”Controls the filter popover state for the call list view:
pub struct CallFilter { pub search: String, pub direction: Option<CallDirection>, pub phone_number_id: Option<Uuid>, pub has_recording: Option<bool>, pub min_duration_secs: Option<i32>, pub time_range: TimeRange, pub tag_ids: Vec<Uuid>, // Filter by linked contact's tags}active_filter_count() returns the number of non-default filters for badge display. Each filter contributes at most 1 to the count — tag_ids counts as active when non-empty.
Analysis Structure
Section titled “Analysis Structure”Each call can have multiple analysis groups (one per analyzer), each with multiple runs:
pub struct CallAnalysisGroup { pub analyzer_id: Uuid, pub analyzer_name: String, // e.g. "Sentiment Analyzer" pub runs: Vec<CallAnalysisRun>,}
pub struct CallAnalysisRun { pub id: Uuid, pub analysis_text: String, // e.g. "No compliance issues found." pub created_at: String,}Previous analysis runs are preserved when re-running — new runs are appended.
Markdown Rendering
Section titled “Markdown Rendering”Both transcription text and analysis results render as markdown in the call details view using the shared Markdown component. This supports headings, lists, bold, and other formatting that AI-generated analysis commonly produces. See Contact Notes — Markdown Component for the full component API.
API Endpoints
Section titled “API Endpoints”| Method | Path | Description |
|---|---|---|
GET | /api/calls?time_range&search&direction&phone_number_id&has_recording&min_duration_secs&tag_ids&page&page_size | List calls with filtering and pagination |
GET | /api/calls/:id | Full details with transcription, analysis, and contact link |
POST | /api/calls/:id/rerun | Re-run all active analyzers against the transcription |
GET | /api/calls/:id/recording | Serve the MP3 recording file |
POST | /api/calls/initiate | Validate and resolve caller ID for an outbound call |
POST | /api/twilio/voice/transfer | Cold transfer active call via Twilio redirect |
All endpoints require an authenticated Session. Calls are scoped via a join on phone_number.organization_id.
Call Permissions
Section titled “Call Permissions”The Call resource defines these permissions in the ABAC system:
| Level | Permission | Description |
|---|---|---|
| Instance | View | Access any call in the org |
| Instance | ViewAssigned | Access calls linked to the member’s assigned contacts |
| Instance | Update | Re-run analysis on a call |
| Collection | List | List all org calls |
| Collection | ListAssigned | List only calls from assigned contacts |
| Collection | MakeCall | Initiate outbound calls and generate Twilio access tokens |
The CallInstance struct carries both org_id and assigned_member_ids (loaded from contact_user):
pub struct CallInstance { pub org_id: Uuid, pub assigned_member_ids: Vec<Uuid>,}Permission Enforcement
Section titled “Permission Enforcement”The call API endpoints enforce ABAC permissions with assigned-access scoping. Members with full permissions see all calls in the org; members with *Assigned permissions see only calls linked to their assigned contacts.
| Endpoint | Permission Required |
|---|---|
GET /api/calls | Call:Collection:List or Call:Collection:ListAssigned |
GET /api/calls/:id | Call:Instance:View or Call:Instance:ViewAssigned |
POST /api/calls/:id/rerun | Call:Instance:View or Call:Instance:ViewAssigned |
GET /api/calls/:id/recording | Call:Instance:View or Call:Instance:ViewAssigned |
POST /api/calls/initiate | Call:Collection:MakeCall |
GET /api/twilio/access-token | Call:Collection:MakeCall |
Assigned-Access Scoping
Section titled “Assigned-Access Scoping”The call list endpoint checks permissions in a two-step cascade:
- If the member has
Call:Collection:List→ return all org calls (no filter) - If the member has
Call:Collection:ListAssigned→ filter calls to those whosecontact_idappears in thecontact_usertable for the current member
let show_all = CallResource::has_collection_permission(&session, CallCollectionPermission::List);if !show_all { CallResource::has_collection_permission(&session, CallCollectionPermission::ListAssigned) .or_forbidden("Not allowed to list calls")?; // Filter: call.contact_id IN (SELECT contact_id FROM contact_user WHERE member_id = session.member_id)}check_call_access Service
Section titled “check_call_access Service”Instance endpoints (get_call_details, serve_recording, rerun_call_analysis) use a shared access-check service that mirrors the Contact access control pattern:
pub async fn check_call_access( db: &DatabaseConnection, session: &Session, call_id: Uuid, action: CallInstanceAction,) -> Result<call::Model, AppError>The service:
- Loads the call by ID, joining on
phone_numberto verify org ownership - Loads
assigned_member_idsfrom thecontact_usertable via the call’scontact_id - Builds a
CallInstancewithorg_idandassigned_member_ids - Delegates to
CallResource::has_instance_permission()which checks:Viewpermission → grants access to any org callViewAssignedpermission → grants access only ifsession.member_idis inassigned_member_ids
The AI assistant’s get_call_details tool also uses check_call_access, so assistants respect the same access boundaries as the UI.
Time Range Filtering
Section titled “Time Range Filtering”The GET /api/calls?time_range endpoint accepts a TimeRange query parameter:
pub enum TimeRange { Today, Yesterday, Last7Days, Last30Days, Last90Days, AllTime, Custom { start: String, end: String },}The API applies bounds using time_range_start() and time_range_end() helpers, filtering on call.created_at. Custom ranges expect ISO date strings ("2026-01-15").
Tag Filtering
Section titled “Tag Filtering”The tag_ids query parameter filters calls by their linked contact’s tags. Pass a comma-separated list of tag UUIDs:
GET /api/calls?tag_ids=uuid1,uuid2,uuid3&time_range=Last30DaysThe server builds a subquery through the contact_contact_tag junction table:
if !tag_ids.is_empty() { let tag_contact_subquery = schemas::contact_contact_tag::Entity::find() .select_only() .column(schemas::contact_contact_tag::Column::ContactId) .filter(schemas::contact_contact_tag::Column::ContactTagId.is_in(tag_ids.clone())) .filter( schemas::contact_contact_tag::Column::ContactTagId.in_subquery( schemas::contact_tag::Entity::find() .select_only() .column(schemas::contact_tag::Column::Id) .filter(schemas::contact_tag::Column::OrganizationId.eq(session.organization.id)) .into_query(), ), ) .into_query(); q = q.filter(schemas::call::Column::ContactId.in_subquery(tag_contact_subquery));}The nested subquery provides defense-in-depth: even if a client sends another org’s tag UUID, the filter only matches tags belonging to the session’s organization.
Tag filtering combines with all other filters additively — select “Inbound” direction + a tag and you get only inbound calls from contacts with that tag.
Cross-Entity Navigation
Section titled “Cross-Entity Navigation”The call details view renders a clickable contact name link when the call has a linked contact. Clicking navigates to the ContactDetailsView.
The server resolves contact info using a lightweight select_only query:
let (contact_id, contact_name) = if let Some(cid) = call.contact_id { let name = schemas::contact::Entity::find_by_id(cid) .select_only() .column(schemas::contact::Column::FirstName) .column(schemas::contact::Column::LastName) .filter(schemas::contact::Column::OrganizationId.eq(session.organization.id)) .into_tuple::<(String, String)>() .one(&db) .await?; match name { Some((first, last)) => (Some(cid.to_string()), Some(contact_display_name(&first, &last))), None => (None, None), }} else { (None, None)};If the contact was deleted or belongs to another org, the UI falls back to showing the phone number.
Recording Endpoint
Section titled “Recording Endpoint”The recording endpoint (serve_recording_api) reads the MP3 file from disk using tokio::fs and returns it with Content-Type: audio/mpeg. Files are stored as {call_sid}.mp3.
Outbound Calling
Section titled “Outbound Calling”Outbound calling uses the Twilio Voice SDK v2.x running in the browser. The SDK is loaded lazily on the first call attempt.
Architecture
Section titled “Architecture”User clicks Call → initiate_call_api() → resolves caller ID → Twilio Device.connect({ To, CallerId, ContactId }) → Twilio hits TwiML webhook (twilio_voice_outbound) → TwiML: <Dial> with <Recording track="both"> → Call connects, recording starts → Call ends → same post-call pipeline as inboundInitiate Call API
Section titled “Initiate Call API”POST /api/calls/initiate validates the member’s phone permissions and returns the caller ID to use:
// Requestpub struct InitiateCallRequest { pub contact_phone: String, pub contact_id: Option<Uuid>, pub phone_number_id: Option<Uuid>, // Explicit caller ID selection}
// Responsepub struct InitiateCallResponse { pub caller_id: String, // E.164 number to use as caller ID pub contact_id: Option<Uuid>,}If phone_number_id is provided, the server validates it against the member’s allowed phones. Otherwise, it auto-selects a number — preferring one with voice_webhook_active, then falling back to the first available.
Twilio Device Lifecycle
Section titled “Twilio Device Lifecycle”The OutboundCallContext manages the device state globally:
pub enum OutboundCallState { Idle, // Device not initialized Initializing, // Loading SDK + generating token Ready, // Device ready, waiting for a call Connecting, // Call ringing Connected, // Call in progress Error(String), // Something went wrong}
pub struct ActiveCall { pub to_number: String, pub caller_id: String, pub contact_id: Option<Uuid>, pub contact_name: Option<String>, pub started_at: String, // RFC 3339}The device stays in Ready state between calls — no re-initialization needed for subsequent calls. Token refresh is handled automatically via the tokenWillExpire event.
TwiML Webhook
Section titled “TwiML Webhook”When Twilio connects the outbound call, it hits POST /api/twilio/voice/outbound. The handler:
- Parses
To,CallerId, andContactIdfrom the form body - Spawns a background task to create the call record (direction:
"outbound") - Returns TwiML with
<Recording track="both">and<Dial>to the destination number
The call record and post-call pipeline (recording, transcription, analysis) follow the same path as inbound calls.
Dial Pad UI
Section titled “Dial Pad UI”The DialPadPanel component provides:
- Number entry — keypad grid with DTMF digits
- Contact autocomplete — searches contacts as you type (debounced, 300ms)
- Caller ID selector — dropdown when the member has multiple assigned phone numbers
- In-call controls — mute toggle, DTMF keypad, and hang up
- Call from contact sidebar — clicking the phone icon on a contact auto-opens the dial pad with the number pre-filled
Key Functions
Section titled “Key Functions”| Function | Description |
|---|---|
initialize_device() | Loads Twilio SDK, generates access token, creates Device |
make_call() | Validates via initiate_call_api, then calls Device.connect() |
hangup_call() | Disconnects active call, resets state to Ready |
toggle_mute() | Toggles mute on the active call |
send_dtmf() | Sends a DTMF digit (validates input to prevent injection) |
Access Token Generation
Section titled “Access Token Generation”GET /api/twilio/access-token returns a short-lived JWT for the Twilio Client SDK. On first request, it auto-provisions:
- A TwiML Application pointing to the outbound voice webhook URL
- An API Key pair for signing tokens
Both are persisted in organization_twilio_settings so subsequent requests skip provisioning.
Call Lifecycle
Section titled “Call Lifecycle”Inbound
Section titled “Inbound”Twilio stream start → Call record created (direction: "inbound", status: "in-progress") → Live AI session active → Call ends → recording callback → post-call pipelineOutbound
Section titled “Outbound”User clicks Call in dial pad → initiate_call_api resolves caller ID → Twilio Device.connect() → TwiML webhook creates call record (direction: "outbound", status: "in-progress") → Call connects with dual-channel recording → Call ends → recording callback → same post-call pipelinePost-Call Pipeline (Both Directions)
Section titled “Post-Call Pipeline (Both Directions)”Twilio fires recording callback → process_twilio_recording() → status = "completed" + duration_secs saved → Recording downloaded → Transcription (async) → Call record updated with recording_url + transcription → Contact enrichment → Analyzer execution → Task extraction → Email reportStatus transitions to "completed" as soon as the recording webhook fires — typically within a second or two of the call ending. UI reflects the completed status immediately rather than waiting 30+ seconds for the full pipeline to finish.
Cold Transfer
Section titled “Cold Transfer”The POST /api/twilio/voice/transfer?phone_number=...&caller_id=... endpoint redirects an active call to a new phone number. The transfer is cold: Twilio fetches new TwiML, the existing media stream disconnects, and the call connects to the new number with the original Loquent number as caller ID.
Implementation uses redirect_call() util (Twilio REST API POST /Calls/{CallSid}.json with Url and Method params).
Call List Display
Section titled “Call List Display”Date Formatting
Section titled “Date Formatting”created_at uses context-aware formatting via format_friendly_datetime() (server-only, in src/shared/utils/date/). The format adapts based on recency:
| Recency | Format | Example |
|---|---|---|
| Today | Time only | 10:30 AM |
| This week | Weekday + time | Mon 10:30 AM |
| This year | Month + day + time | Jan 15, 10:30 AM |
| Older | Month + day + year | Jan 15, 2024 |
The full datetime (created_at_full) displays as a tooltip on hover. Both values are computed server-side using the organization’s timezone — no client-side formatting needed.
Client-Side Date Utilities
Section titled “Client-Side Date Utilities”Two shared utilities handle date formatting on the client:
format_datetime_short — compact display for list cards and table cells. Parses both datetime-local format ("2026-03-28T15:00") and timezone-aware format ("2026-04-13 22:00:32 -04:00"). Midnight times render as date-only ("Mar 28"); others include time ("Mar 28, 3:00 PM").
format_relative_time — bidirectional relative labels. Returns past labels ("8h ago", "3d ago") or future labels ("in 45m", "in 3d") based on the sign of the time difference. Parses datetime-local format for compatibility with DateTimePicker values.
Contact Column
Section titled “Contact Column”The Contact column replaces the previous Call Route column. Display depends on whether the call has a linked contact:
- With contact: The contact name renders as a clickable link to the contact detail view, with the phone number in small text below. The link uses
stop_propagation()to prevent triggering the row’s click handler. - Without contact: The phone number displays as plain text.
Routing Columns
Section titled “Routing Columns”| Column | Source | Example |
|---|---|---|
| Contact | contact_name / from_to_number | Jane Doe or +15551234567 -> +15559876543 |
| Received On | received_on (derived from phone_number.friendly_name) | Front Desk (+15559876543) |
Access Control
Section titled “Access Control”Server-Side
Section titled “Server-Side”All call endpoints enforce ABAC permissions. See Permission Enforcement above for the full table. The check_call_access service centralizes instance-level checks across detail, recording, rerun, and AI assistant endpoints.
Client-Side
Section titled “Client-Side”Both call views enforce permissions using AccessDenied guards:
- Call list (
CallListView) — checksCall:Collection:ListorCall:Collection:ListAssigned. Users without either permission see anAccessDeniedcard. - Call details (
CallDetailsView) — checksCall:Instance:VieworCall:Instance:ViewAssigned. Users without either permission see anAccessDeniedcard with a back-navigation link.
The Calls sidebar link is gated behind Call:Collection:List or Call:Collection:ListAssigned. Users without call permissions don’t see the link at all.
The Dial pad button (DialPadButton) is hidden when the user lacks Call:Collection:MakeCall. The call button in the contact sidebar is also hidden without MakeCall.
The rerun-analysis button uses CallInstancePermission::Update — see Permission-Aware UI for the pattern.
When a rerun fails, the error is surfaced as a user-visible toast via toaster.mutation_error() instead of a silent server log.
UI State
Section titled “UI State”The module includes two independent PLAYING_SID: GlobalSignal<Option<String>> signals — one in the list view and one in the detail view — to track which recording is currently playing.
Module Structure
Section titled “Module Structure”src/mods/call/├── api/ # List, details, rerun, recording, initiate endpoints├── components/ # Call list, call details, dial pad, call controls├── constants/ # Recording serve path├── context/ # OutboundCallContext — global calling state + JS interop├── types/ # Call, CallDetails, CallDirection, OutboundCallState, ActiveCall└── views/ # List and detail page viewsRelated Modules
Section titled “Related Modules”- Twilio — creates call records, triggers post-call pipeline
- Analyzer — runs analysis against transcriptions
- Contact — linked via
contact_id, enriched post-call - Report — generates email reports from call data
- Settings — phone number restrictions per member (caller ID access)
- Phone —
received_onis derived from the phone number’sfriendly_name