Frontend¶
The agent console in frontend/: a SvelteKit application that ships as a static SPA and
doubles as the Android and iOS app through Capacitor.
Stack¶
- SvelteKit 2.5 on Svelte 4 (
frontend/package.json:53,77) — no runes @sveltejs/adapter-staticwithssr = falseandprerender = trueglobally (frontend/svelte.config.js:1,10,src/routes/+layout.js:3-4), so there is no server side at all@twilio/voice-sdkfor browser callingfirebasefor Firestore live updates and FCM@sveltestack/svelte-queryfor the data layer, Tailwind 3 + Flowbite for UI,sveltekit-i18nfor translations, Capacitor 7 for mobile- Storybook 8, with 92 story files across 319 components
Two query libraries are installed
Both @sveltestack/svelte-query@1.6 and @tanstack/svelte-query@5 are dependencies
(frontend/package.json:98-100). The controller layer described below uses the
@sveltestack one. Do not start new work against @tanstack until someone decides to
migrate deliberately.
Scripts (frontend/package.json:4-34): dev runs Vite on port 5001 with --host;
build / build:app produce the static bundle; build:android chains the build with
cap sync android && cap open android; dev:android / dev:ios run Vite and Capacitor
together for on-device hot reload; assemble:debug, assemble:release and
bundle:release shell out to Gradle; validate runs svelte-check.
Routes¶
Two layout groups. (unauthenticated) holds signin, recoveryPass, verifyRequest and
error. (authenticated) mounts the application shell — header, sidebar drawer, the
<CallDialog /> softphone, the company-error banner and the last-call-notes dialog — and
gates everything on SessionCTL.authCeck()
(src/routes/(authenticated)/+layout.svelte:20-25).
| Route | Screen |
|---|---|
dashboard |
Metrics |
phone/[[contactId]]/[[open=todos]] |
Softphone keypad, contacts, notes |
calls/[[callId]]/[[open=todosExtras]] |
Call log and traces |
messages/[[phone]]/[[open=todos]] |
SMS threads |
contacts, contacts/native |
Address book and device contact import |
notifications, tasks, profile, profile/login_methods |
Account surfaces |
phone_numbers/own |
Numbers owned by the company |
subscription/phone_number/{success,canceled} |
Stripe return URLs |
settings/[[setting]] |
Master-detail settings, about 18 sections |
debug |
Internal diagnostics |
src/routes/+page.svelte:10-22 is a spinner: it verifies the token, consumes any native
notification payload, then redirects to /dashboard.
The [[open=todos]] and [[open=todosExtras]] optional segments are matched by
src/params/todos.ts and src/params/todosExtras.ts; their only job is to open a side
panel, so a panel is a URL and therefore shareable and back-button friendly.
settings/[[setting]] is one page driven by
src/lib/components/settings/SettingsList.svelte, whose sections are gated individually
with hasPermission: users, companies, phone-number masks, notification types, company
profile, login methods, recordings, attention hours, missed-call SMS, call redirection, SIP
domains, SIP credential lists, SIP credentials, phone numbers, extensions, voice messages,
payment management and logs.
The controller pattern¶
src/lib/actions/ holds 210 TypeScript files and is where nearly all application state
lives — src/lib/store/ contains only three small UI stores. Four base classes in
src/lib/actions/controller/ define the pattern:
| Class | Role |
|---|---|
Store |
A class that is a Svelte store: subscribe delegates to an internal writable(this), and update() re-emits itself. Components can write $ctl.foo |
Api |
Holds a FetcherDef, defaulting to new Fetcher() (Api.ts:20-22) |
QueryCTL<Result> |
Wraps useQuery with queryKey: qId and queryFn: () => this.fetch() (QueryCTL.ts:54-70) |
MutationCTL<Vars, Result> |
Wraps useMutation; subclasses implement onMutationExecute (MutationCTL.ts:58-99) |
Controller<Result, Api, Query> |
Composes one query and N mutations, re-exposing data / isLoading / error (Controller.ts:43-69) |
A worked example — src/lib/actions/sip/credentials/:
CredentialsApi.tsis pure HTTP.fetchCredentials()gets/sip/credentials/(:11);saveCredential()posts or puts withobjToFormData(:27-33);setCredentialStatus()puts/sip/credential/call_status/{id}(:72).CredentialsQueryCTL.tsis seventeen lines:fetch()callsapi.fetchCredentials().CredentialsSaveMutationCTL.ts,CredentialsRemoveMutationCTL.tsandCredentialSetStatusMutationCTL.tseach declare aVarsinterface and invalidateqIdon success.CredentialsCTL.tsis the façade a component instantiates. It setsqId = ['sip','credentials'](:31), wires the three mutations and exposessave()/removeOne()/setStatus()plusisSaving,isRemovingOneandonSaveSuccess-style callback setters (:59-120).
That five-file shape repeats for roughly 28 domains. When adding an entity, copy the closest existing folder rather than inventing a new arrangement.
Talking to the backend¶
src/lib/actions/session/Fetcher.ts is the only HTTP client. The base URL is
import.meta.env.VITE_API_URL (:8). authFetch attaches
Authorization: Bearer <accessToken> from localStorage; on a 401 it calls
refreshAccessToken() — a POST to ${VITE_API_URL}/login/refresh with the refresh token as
form data — and retries the request once (:117-127, :54-78). With no token at all it
clears storage and redirects to /signin, unless the user is already on /signin or
/recoveryPass (:94-100).
Tokens live in localStorage as access_token and refresh_token
(SessionStorage.ts:29-34). HeartBeat.ts pings the session API every five seconds while
authenticated.
Two leftovers worth knowing: every request carries ngrok-skip-browser-warning and
bypass-tunnel-reminder headers (Fetcher.ts:44-45) from the tunnel-based development
days, and src/lib/fetchWithRefresh.ts is explicitly @deprecated in favour of Fetcher.
Real-time data does not come over this client — see
Architecture overview. The "WebSocket"
comments in MsgCTL.ts:44,110,217 are stale.
The softphone¶
src/lib/components/call-dialog/CallDialog.svelte:14-22 is the entry point: once a company
id is known it calls newCallClient(companyId) and initialize().
src/lib/actions/twilio/call.ts:13-18 then picks an implementation by platform —
WebTwilioClient on the web, TwilioClientNative on Android and iOS.
Shared base — TwilioClient.ts keeps the state that both implementations need: contact
matching against ContactsCTL, setAvailableState() against /availableCallTrace, and
addCallee() posting /call/add_callee with the call_sid (:181-209). The state fields
themselves live in CallClientState.ts.
Web — TwilioClientWeb.ts fetches ${VITE_API_URL}/calls/twilio/token (:48-53),
constructs new Device(data.token) and registers it (:72-83), and handles incoming,
error and registered (:73-75). Outbound calls go through
device.connect({ params: { To } }) (:202), with per-call listeners for accept, reject,
disconnect, cancel and error (:154-174). DTMF uses sendDigits; speaker selection uses
device.audio.speakerDevices. On a device error it destroys and re-initialises after five
seconds unless VITE_TWILIO_ERROR_RELOAD_DISABLED is set (:105-113). Hold is not
supported here — _toggleCallOnHold() returns false.
Native — TwilioClinetNative.ts (the filename is misspelled in the repository)
delegates to a custom Capacitor plugin, CallPlugin
(src/lib/plugins/call_plugin.ts:33). The Android side is Kotlin:
android/app/src/main/java/com/towit/links/plugins/CallPlugin.kt with services/CallService.kt,
call/CallListener.kt, CallBroadcastReceiver.kt and sound/SoundPoolManager.kt. The iOS
counterpart sits under ios/App/App/plugin. Hold is native-only.
Internationalisation¶
Two locales, en and es (static/locales/lang.json). Translations are fetched at
runtime, not bundled: getLang() builds
loader: () => fetch('/locales/${lang}/${file}.json')
(src/lib/locales/langs_loaders.ts:63-67). langs_config.ts declares about seventy
namespaces, each mapping a file to the routes that need it ('' means global).
index.ts:25-35 instantiates sveltekit-i18n with fallbackLocale: 'en' and exports the
reactive t plus a non-reactive T helper (:102-103). The chosen language is persisted
by LocaleStorage; +layout.js:14-15 falls back to navigator.language. Individual pages
call loadTranslations($locale, '/settings') to pull route-scoped namespaces.
Environment variables¶
The set actually read by src/ is small: VITE_API_URL, the PUBLIC_FIREBASE_* keys, and
the optional VITE_DEV and VITE_TWILIO_ERROR_RELOAD_DISABLED. frontend/.env.example is
stale — it lists VITE_TWILIO_URL and VITE_WSAPI_URL, which nothing reads, and names the
Firebase keys VITE_FIREBASE_* while
src/lib/actions/firebase/FirebaseApp.ts:9-27 reads PUBLIC_FIREBASE_* through
$env/static/public. See Configuration.
Deployment targets¶
Four paths, all serving the same build/ output — see
Deployment for the full picture.