8.3 KiB
LibreChat Mobile Architecture Reference
Condensed architecture guide for the LibreChat Mobile app. Read module-specific CLAUDE.md files for full details.
Module Dependency Graph
app
├── feature/auth → core/common, core/model, core/network, core/data, core/ui
├── feature/chat → core/common, core/model, core/network, core/data, core/ui
├── feature/conversations → core/common, core/model, core/network, core/data, core/ui
├── feature/settings → core/common, core/model, core/data, core/ui
├── feature/agents → core/common, core/model, core/data, core/ui
├── feature/files → core/common, core/model, core/data, core/ui
└── core/data → core/common, core/model, core/network
core/network → core/common, core/model
core/ui → core/common, core/model
core/model → (none — pure Kotlin, no Android deps)
core/common → (none — lowest layer)
Rules:
- Feature modules depend on
:core:*only, never on each other. - Feature modules currently live under
feature/{auth, chat, conversations, settings, agents, files}. There is nofeature/promptsyet — prompts UI is not implemented.
Data Flow Pattern (Unidirectional)
UI (Composable) → ViewModel (StateFlow) → Repository (interface) → API (Ktor) / Room (cache)
- ViewModels expose
StateFlow<UiState>to Compose - Repositories define interfaces in
:core:data, implemented with Koin DI - Room is a read-through cache; server is always source of truth
safeApiCall Pattern
All repository network calls use this wrapper (defined in :core:common):
suspend fun <T> safeApiCall(block: suspend () -> T): Result<T> =
try {
Result.Success(block())
} catch (e: ClientRequestException) { // 4xx
Result.Error(e, parseErrorMessage(e.response.bodyAsText()))
} catch (e: ServerResponseException) { // 5xx
Result.Error(e, "Server error. Please try again.")
} catch (e: HttpRequestTimeoutException) {
Result.Error(e, "Request timed out.")
} catch (e: IOException) {
Result.Error(e, "Network unavailable. Check your connection.")
} catch (e: SerializationException) {
Result.Error(e, "Unexpected response format.")
}
API Service Pattern (core/network)
Each API service is a Ktor-backed class with a primary constructor that takes an HttpClient.
It is registered in a Koin module — no annotations.
class ConversationsApi(private val client: HttpClient) {
suspend fun getConversations(pageNumber: Int, limit: Int, isArchived: Boolean): PaginatedConversations =
client.get("/api/convos") {
parameter("pageNumber", pageNumber.toString())
parameter("limit", limit.toString())
parameter("isArchived", isArchived.toString())
}.body()
}
DI registration (in core/network/src/commonMain/.../di/NetworkModule.kt):
val networkModule = module {
singleOf(::ConversationsApi)
// ...
}
Backend quirk: Mutation endpoints wrap body in arg field: { "arg": { ... } }
Repository Pattern (core/data)
Interface in :core:data, implementation in the same module, registered with Koin:
interface ConversationRepository {
fun observeConversations(...): Flow<Result<List<Conversation>>>
suspend fun updateTitle(id: String, title: String): Result<Conversation>
}
class ConversationRepositoryImpl(
private val api: ConversationsApi,
private val dao: ConversationDao,
private val mapper: EntityMapper,
private val ioDispatcher: CoroutineDispatcher,
) : ConversationRepository { ... }
DI registration:
val dataModule = module {
single<ConversationRepository> {
ConversationRepositoryImpl(
api = get(),
dao = get(),
mapper = get(),
ioDispatcher = get(qualifier = named("io")),
)
}
}
Dispatchers come from :core:common via Koin named qualifiers (named("io"), named("default"), named("main")).
Room Entity Pattern (core/data)
@Entity(tableName = "conversations")
data class ConversationEntity(
@PrimaryKey val conversationId: String,
val title: String?,
val endpoint: String?,
val tagsJson: String?, // Complex fields stored as JSON strings via TypeConverters
)
DAO read methods return Flow<T> for reactive observation.
ViewModel Pattern (feature modules)
ViewModels extend ViewModel (or androidx.lifecycle.ViewModel for KMP) and are registered
with viewModelOf(::X) in the feature's Koin module:
class ConversationsViewModel(
private val conversationRepository: ConversationRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(ConversationsUiState())
val uiState: StateFlow<ConversationsUiState> = _uiState.asStateFlow()
fun loadConversations() {
viewModelScope.launch {
conversationRepository.observeConversations(...)
.collect { result ->
_uiState.update { state ->
when (result) {
is Result.Success -> state.copy(conversations = result.data)
is Result.Error -> state.copy(error = result.message)
is Result.Loading -> state.copy(isLoading = true)
}
}
}
}
}
}
DI:
val conversationsModule = module {
viewModelOf(::ConversationsViewModel)
}
Registered in LibreChatApplication.kt:
startKoin {
modules(
commonModule, networkModule, dataModule, uiModule,
authModule, chatModule, conversationsModule, settingsModule,
agentsModule, filesModule,
)
}
In the screen composable:
@Composable
fun ConversationsScreen(viewModel: ConversationsViewModel = koinViewModel()) { ... }
Do NOT use Hilt (@HiltViewModel, @Inject, hiltViewModel()) anywhere — the project is Koin-only and KMP-ready.
SSE Streaming (core/network)
Custom line parser over raw ByteReadChannel (do NOT use Ktor SSE plugin).
Two-phase protocol:
POST /api/agents/chat→ returns{ streamId }(streamId === conversationId)GET /api/agents/chat/stream/:streamIdopens SSE stream
SseConnectionManager handles lifecycle: start, reconnect (exponential backoff), abort, StateFlow<StreamingState>.
iOS note: iOS uses a custom NWConnection-based HTTP/1.1 transport
(core/network/src/iosMain/.../sse/SseHttpTransport.ios.kt) to bypass NSURLSession's
undocumented text/* content-type buffering. When editing SSE logic, always check iosMain
sources — the iOS path is NOT just commonMain + a thin shim.
KMP Source Set Layout
Every KMP module has (minimally):
src/commonMain/kotlin/— shared logicsrc/androidMain/kotlin/— Android-specific actuals / platform bridgessrc/iosMain/kotlin/— iOS-specific actuals / platform bridges (Network.framework, Keychain, etc.)
When an expect class or function is declared in commonMain, matching actual declarations
must exist in BOTH androidMain and iosMain. Forgetting iosMain compiles locally
(because ./gradlew assembleDebug only runs the Android targets) but breaks
./gradlew :shared:linkDebugFrameworkIosSimulatorArm64.
Naming Conventions
- API classes:
{Domain}Api.kt(e.g.,ConversationsApi.kt) - Repository:
{Domain}Repository.kt(interface) +{Domain}RepositoryImpl.kt - ViewModel:
{Feature}ViewModel.kt - Screen composable:
{Feature}Screen.kt - UI state:
{Feature}UiStatedata class - Room entity:
{Domain}Entity.kt - Room DAO:
{Domain}Dao.kt - Koin module:
{Feature}Module.kt(feature) /{Layer}Module.kt(core) - Convention plugins:
librechat.kmp.{type}(KMP) orlibrechat.mobile.{type}(Android-only)
Build System
- Gradle 9.4.1, AGP 9.1.0, Kotlin 2.3.20, compileSdk 36, minSdk 26
- Compose Multiplatform 1.11.0-beta01, Compose BOM 2025.05.00
- 13 convention plugins under
build-logic/convention/src/main/kotlin/ - Version catalog:
gradle/libs.versions.toml - Navigation 3 (Nav3):
NavDisplay+NavBackStack<NavKey>+entryProvider - DI: Koin (KMP-ready — no Hilt anywhere)
- Convention plugin
apply()usesoverride fun apply(target: Project) { with(target) { ... } }