Skip to content

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-static with ssr = false and prerender = true globally (frontend/svelte.config.js:1,10, src/routes/+layout.js:3-4), so there is no server side at all
  • @twilio/voice-sdk for browser calling
  • firebase for Firestore live updates and FCM
  • @sveltestack/svelte-query for the data layer, Tailwind 3 + Flowbite for UI, sveltekit-i18n for 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.ts is pure HTTP. fetchCredentials() gets /sip/credentials/ (:11); saveCredential() posts or puts with objToFormData (:27-33); setCredentialStatus() puts /sip/credential/call_status/{id} (:72).
  • CredentialsQueryCTL.ts is seventeen lines: fetch() calls api.fetchCredentials().
  • CredentialsSaveMutationCTL.ts, CredentialsRemoveMutationCTL.ts and CredentialSetStatusMutationCTL.ts each declare a Vars interface and invalidate qId on success.
  • CredentialsCTL.ts is the façade a component instantiates. It sets qId = ['sip','credentials'] (:31), wires the three mutations and exposes save() / removeOne() / setStatus() plus isSaving, isRemovingOne and onSaveSuccess-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.