Skip to content

Phone (Voice) Partial

The Phone capability gives a tenant AI voice calls in both directions — inbound calls answered by a Vapi assistant, and outbound calls placed by a person, a BPMN process, or a workflow action. It is implemented entirely inside luke-core-engine under com.luke.engine.capability.phone, backed by Vapi.ai as the telephony + voice-AI provider. Core owns only the audit + orchestration layer: every call is recorded as a luke_phone_calls row, wrapped in a per-tenant Camunda PhoneCallProcess, and settled from Vapi's webhooks. The live media (dialing, speech, the assistant) all runs in Vapi.

The capability is seeded as PHONE"Phone / Voice — Inbound & outbound voice calls via Vapi." — tier STANDARD, route /phone (see CapabilitySeed).

This page is written against the real code in luke-core-engine and the /phone section of luke-consumer-ui. Where a piece of the runtime is data-layer-only, it says so explicitly.

Components at a glance

Data model

Four tables (Flyway V8__phone_tables.sql), all in core's currentSchema (Strategy A), each tenant-isolated by an explicit tenant_id column plus tenant-scoped repository finders.

Entity / TablePurposeNotable columns
PhoneCallluke_phone_callsOne audit row per call (inbound or outbound) and the record the Camunda process wrapsdirection (INBOUND/OUTBOUND), status, vapiCallId (unique — the webhook join key), phoneNumberId, assistantId, customerNumber (E.164), endedReason, transcript (text), recordingUrl, summary (text), cost (double), analysis (JSON map), metadata (JSON map), errorMessage, plus Camunda binding businessKey / processInstanceId / processStatus, @Version optimistic lock, and createdAt/startedAt/endedAt
PhoneNumberluke_phone_numbersA number the tenant owns in Vapi (Vapi-provided, or BYO Twilio/Telnyx/Vonage imported into Vapi)vapiNumberId (unique — used as phoneNumberId on outbound calls), number (E.164), provider (vapi|twilio|telnyx|vonage), name, assistantId (answers inbound on this number)
PhoneSettingsluke_phone_settingsOne row per tenant (id == tenantId): the defaults used when a call omits themhasApiKey (flag only), defaultAssistantId, defaultPhoneNumberId
PhoneCallProcessOutboxluke_phone_call_outboxTransactional outbox that drives the Camunda process; one row per callbusinessKey (unique, == call id), callId, direction, state (QUEUED → STARTED → CLOSED / FAILED), processInstanceId, retryCount

Secret handling: the Vapi private API key is never stored on any of these rows. It lives encrypted in the shared SecretStore under key vapi.api-key, resolved per-tenant by VapiCredentials (with a global luke.phone.vapi.api-key fallback for dev / single-account mode). PhoneSettings.hasApiKey is only a boolean marker; the key is write-only over HTTP.

PhoneCallStatus values: QUEUED, RINGING, IN_PROGRESS, ENDED, FAILED. ENDED and FAILED are terminal. PhoneCallStatus.fromVapi(...) maps Vapi's call.status (scheduled/queued → QUEUED, ringing → RINGING, in-progress/forwarding → IN_PROGRESS, ended → ENDED); unknown values leave the status unchanged.

Outbound call flow

A call can be placed from three entrypoints — all of which converge on PhoneCallService.placeOutbound(...):

  • Tenant UIPOST /api/phone-calls (PhoneController.call), capability-guarded.
  • BPMN / server-to-serverPOST /api/internal/phone-calls (InternalPhoneController), behind the shared-secret InternalAuthFilter on /api/internal/**; actor defaults to system.
  • Workflow actionPhoneActionHandler (capability() == "phone"), the outbound rail of the WORKFLOW engine; a phone / call node resolves in the number.

Key point: placeOutbound never throws on a Vapi failure — the call row is always saved, and a provider error is recorded as status = FAILED + errorMessage (caller-input mistakes, by contrast, throw 4xx). The Camunda process is started separately by the outbox poller, not inline, so the HTTP response returns immediately.

Function / endpointWhereResponsibility
PhoneController.callPOST /api/phone-callsTenant-facing placement; requires X-Tenant-Id, records X-User-Id as initiator
InternalPhoneController.callPOST /api/internal/phone-callsSame, for processes; actor system unless X-User-Id given
PhoneActionHandler.executeWorkflow railMaps a phone/call node's input → OutboundCallRequest, resolves placeholders
PhoneCallService.placeOutboundServiceResolve defaults + key, persist QUEUED row + outbox, place via Vapi, flip to RINGING/FAILED
VapiClient.createCallVapi clientPOST {base}/call with phoneNumberId, customer.number, assistantId, assistantOverrides.variableValues, metadata; returns CallResult
PhoneCallService.persistNewCallService (private)Saves the call and its outbox row together so the process is started exactly once
PhoneProcessService.startCamundaIdempotent (on businessKey) start of PhoneCallProcess, tenant-scoped

Inbound call flow

Inbound calls are never placed by us — they are observed from Vapi's webhook. When a caller dials a tenant's number, Vapi sends an assistant-request, and the engine answers with which assistant should pick up.

Message typeHandlerEffect
assistant-request (sync)handleAssistantRequestresolveInboundRouting + recordInboundResolve tenant + assistant from the dialed number, record an INBOUND/RINGING call, return {assistantId}. Unknown number → declines to route
tool-calls (sync)handleToolCallsresolveTenant + VapiToolHandler.handleDispatch each mid-call tool by tool-call id within Vapi's ~7.5s window; returns a results[] entry each
status-update (async)handleStatusUpdateapplyStatusAdvance the call's lifecycle (no-op if untracked or already terminal)
end-of-call-report (async)handleEndReportapplyEndReportStamp endedReason / transcript / recordingUrl / summary / cost / analysis, mark ENDED or FAILED

The webhook never returns 500 on our own bug — every handler is wrapped so an internal error is logged and acknowledged with 200, keeping Vapi from hammering retries. Handlers are idempotent (recordInbound and applyEndReport both short-circuit on an existing/terminal row) because Vapi may redeliver.

Correlation back into a process: once a call is terminal, the outbox poller's second phase (maybeClose) correlates the PhoneCallEnded message into the parked PhoneCallProcess, which runs PhoneCallWriteBackDelegate (${phoneCallWriteBackDelegate}) to stamp processStatus = CLOSED.

Component reference

ComponentTypeResponsibilityKey methods
PhoneControllerREST controllerTenant call API, capability-guardedcall, list (paged, capped 200), get
InternalPhoneControllerREST controllerServer-to-server outbound for BPMN, behind InternalAuthFiltercall
PhoneNumberControllerREST controllerProvision / list a tenant's Vapi numbersprovision, list, get
PhoneSettingsControllerREST controllerRead/update defaults; connect/disconnect Vapi key (write-only)current, updateDefaults, connectApiKey, disconnectApiKey
PublicPhoneWebhookControllerREST controllerThe one unauthenticated path — Vapi webhooks, shared-secret verifiedwebhook, handleAssistantRequest, handleToolCalls, handleStatusUpdate, handleEndReport
PhoneActionHandlerCapability action handlerWORKFLOW outbound rail for phone/callcapability, execute
PhoneCallServiceServiceFull call lifecycle both directions; every call recordedplaceOutbound, recordInbound, resolveInboundRouting, resolveTenant, applyStatus, applyEndReport
PhoneNumberServiceServiceProvision Vapi / import carrier numbers (hard error on failure)provision, list, get
PhoneSettingsServiceServiceDefaults + key connect/disconnect (delegates key to VapiCredentials)get, updateDefaults, connectApiKey, disconnectApiKey
VapiClientHTTP clientThin Vapi REST client (Bearer per-call key), never throws on provider failurecreateCall, buyVapiNumber, importNumber, listNumbers
VapiCredentialsServiceResolve per-tenant Vapi key from SecretStore (+ global fallback)resolveApiKey, putApiKey, clearApiKey, hasTenantKey
VapiWebhookVerifierComponentConstant-time compare of X-Vapi-Secret; default-lenient in dev, pin in prodverify
VapiToolHandler / DefaultVapiToolHandlerInterface + default beanExtension point for mid-call tools; default returns "not configured"handle
PhoneCallProcessOutboxConsumerScheduled pollerQUEUED→STARTED (start process) then STARTED→CLOSED (correlate ended)drain, start, maybeClose
PhoneProcessServiceCamunda facadeStart / correlate the per-tenant BPMN, in-processstart, correlateEnded
PhoneProcessDeployerStartup deployerDeploy PhoneCallProcess.bpmn per tenant, idempotentdeployFor, backfillExistingTenants
PhoneCallWriteBackDelegateJavaDelegateBest-effort stamp processStatus = CLOSED on the callexecute

Endpoints

Method + pathAuthPurpose
POST /api/phone-callsGateway auth + PHONE writePlace an outbound call
GET /api/phone-callsGateway auth + PHONE readList calls (paged, status/direction filters, newest first)
GET /api/phone-calls/{id}Gateway auth + PHONE readOne call (transcript / recording / summary / cost)
POST /api/phone-numbersGateway auth + PHONE writeProvision a Vapi number or import a carrier number
GET /api/phone-numbers · GET /api/phone-numbers/{id}Gateway auth + PHONE readList / get numbers
GET /api/phone-settingsGateway auth + PHONE readDefaults + hasApiKey flag
PUT /api/phone-settingsGateway auth + PHONE writeUpdate default assistant / number
POST /api/phone-settings/api-key · DELETE /api/phone-settings/api-keyGateway auth + PHONE writeConnect / disconnect the tenant Vapi key (write-only)
POST /api/internal/phone-callsShared secret (InternalAuthFilter)Server-to-server outbound for BPMN processes
POST /api/public/phone/webhookX-Vapi-Secret shared secret (VapiWebhookVerifier)Vapi server webhooks — the only unauthenticated route; not capability-guarded by design

The PHONE capability interception is wired in AccessWebConfig over /api/phone-calls/**, /api/phone-numbers/**, /api/phone-settings/**. The public webhook (/api/public/phone/**) and the internal endpoint are intentionally not listed there.

Webhook auth config: luke.phone.vapi.webhook-secret holds the expected secret; when unset the verifier is default-lenient (allows + warns) so dev/qa work without setup. In production set the secret and luke.phone.vapi.webhook-require-secret=true (the prod profile should pin this) so an unsigned/mismatched call is rejected 401.

Status & gaps

The capability is marked Partial — the data + orchestration layer is complete and green, but a few edges are deliberately stubbed or provider-dependent:

  • Runtime is real, but the process is a thin wrapper. PhoneCallProcess.bpmn currently does exactly two things: park at an "Await Call End" receive task, then run "Record Call Outcome" (PhoneCallWriteBackDelegate). There is no branching on outcome, no retry/redial, and no post-call routing yet — the business value is the recorded audit row + the correlation hook a larger process can build on.
  • Mid-call tools are unimplemented by default. VapiToolHandler is an extension point; the shipped DefaultVapiToolHandler answers every tool call with "not configured". A tenant must supply their own VapiToolHandler bean to give the assistant real functions.
  • No native assistant management. The engine references Vapi assistantIds but does not create or edit assistants — those are managed in Vapi directly. The UI only picks defaults.
  • Number model is 1:1 in the UI. phoneApi.getMyNumber treats a workspace as owning at most one number, even though the backend (PhoneNumberRepository) supports many.
  • Single-poller requirement for HA. With more than one engine node, the outbox poller must run on exactly one (luke.phone.outbox-enabled=false on the others); the unique businessKey is only a backstop.
  • Provider lock-in. Everything terminal (dialing, speech, transcript, recording, cost) comes from Vapi; there is no fallback provider.

See the Completeness Scorecard for how this rates against the other capabilities. Related: Core Engine (host of the phone module) and Consumer UI (the /phone section — Phone.tsx, CallDetail.tsx, PhoneSettingsDrawer.tsx, phoneApi.ts).

Lukeflow Manual · documentation snapshot July 2026